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
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `trellis mcp renew` — scheduled, non-interactive renewal of stored OAuth
|
|
3
|
+
* tokens (trellis-mcp-oauth-renew).
|
|
4
|
+
*
|
|
5
|
+
* Deliberately an orchestrator and nothing more: the refresh itself is
|
|
6
|
+
* `ensureFreshToken`, the same function the gateway calls before every
|
|
7
|
+
* upstream request, with the same lock, skew, and rotation semantics
|
|
8
|
+
* (design.md D1). This command's entire job is to run that path *before*
|
|
9
|
+
* an Agent session discovers a dead credential as a 401, and to say
|
|
10
|
+
* plainly which servers need a human.
|
|
11
|
+
*
|
|
12
|
+
* Never opens a browser, never performs an initial grant — that remains
|
|
13
|
+
* `trellis mcp auth` (mcp-gateway-hosting design.md D9). Scope is
|
|
14
|
+
* canonical Trellis-owned OAuth servers only: classification is explicit,
|
|
15
|
+
* never inferred from token files or URLs (design.md D5), and since
|
|
16
|
+
* trellis-desktop-oauth-ownership an explicit `owner: trellis` is what
|
|
17
|
+
* makes a credential Trellis's responsibility at all — agent-owned
|
|
18
|
+
* servers are authorized by the Agent's own client, which neither reads
|
|
19
|
+
* Trellis's token store nor can be renewed from here, so reporting them
|
|
20
|
+
* as `no-token` would cry wolf on every run.
|
|
21
|
+
*/
|
|
22
|
+
import { type OAuthRenewStatus } from "../lib/oauth/renew.js";
|
|
23
|
+
import type { FetchLike } from "../lib/oauth/discovery.js";
|
|
24
|
+
export interface RunMcpRenewOptions {
|
|
25
|
+
json?: boolean;
|
|
26
|
+
dryRun?: boolean;
|
|
27
|
+
/** Defaults to the real `~`; overridable for tests — the seam every
|
|
28
|
+
* other Trellis entry point exposes. */
|
|
29
|
+
homeDir?: string;
|
|
30
|
+
/** Test seam for the token-endpoint call — an interval-scheduled run
|
|
31
|
+
* must use the real fetch, so this is not a supported user flag. */
|
|
32
|
+
fetchImpl?: FetchLike;
|
|
33
|
+
}
|
|
34
|
+
export interface McpRenewEntry {
|
|
35
|
+
server: string;
|
|
36
|
+
status: OAuthRenewStatus;
|
|
37
|
+
expiresAt?: number;
|
|
38
|
+
remediation?: string;
|
|
39
|
+
}
|
|
40
|
+
export interface McpRenewResult {
|
|
41
|
+
entries: McpRenewEntry[];
|
|
42
|
+
/** True when every classified server is healthy without a human step
|
|
43
|
+
* (design.md D6). The vacuous case — zero OAuth servers — is ok. */
|
|
44
|
+
ok: boolean;
|
|
45
|
+
}
|
|
46
|
+
/** Exported so tests and `doctor` reuse the exact iteration/scope rule
|
|
47
|
+
* without capturing stdout or re-deriving it (design.md D2's one-rule
|
|
48
|
+
* property). Read-only when `dryRun`: no network, no writes. */
|
|
49
|
+
export declare function collectRenewEntries(opts: RunMcpRenewOptions): Promise<McpRenewResult>;
|
|
50
|
+
export declare function runMcpRenew(opts: RunMcpRenewOptions): Promise<{
|
|
51
|
+
exitCode: number;
|
|
52
|
+
}>;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `trellis mcp renew` — scheduled, non-interactive renewal of stored OAuth
|
|
3
|
+
* tokens (trellis-mcp-oauth-renew).
|
|
4
|
+
*
|
|
5
|
+
* Deliberately an orchestrator and nothing more: the refresh itself is
|
|
6
|
+
* `ensureFreshToken`, the same function the gateway calls before every
|
|
7
|
+
* upstream request, with the same lock, skew, and rotation semantics
|
|
8
|
+
* (design.md D1). This command's entire job is to run that path *before*
|
|
9
|
+
* an Agent session discovers a dead credential as a 401, and to say
|
|
10
|
+
* plainly which servers need a human.
|
|
11
|
+
*
|
|
12
|
+
* Never opens a browser, never performs an initial grant — that remains
|
|
13
|
+
* `trellis mcp auth` (mcp-gateway-hosting design.md D9). Scope is
|
|
14
|
+
* canonical Trellis-owned OAuth servers only: classification is explicit,
|
|
15
|
+
* never inferred from token files or URLs (design.md D5), and since
|
|
16
|
+
* trellis-desktop-oauth-ownership an explicit `owner: trellis` is what
|
|
17
|
+
* makes a credential Trellis's responsibility at all — agent-owned
|
|
18
|
+
* servers are authorized by the Agent's own client, which neither reads
|
|
19
|
+
* Trellis's token store nor can be renewed from here, so reporting them
|
|
20
|
+
* as `no-token` would cry wolf on every run.
|
|
21
|
+
*/
|
|
22
|
+
import { homedir } from "node:os";
|
|
23
|
+
import { loadCanonicalSource } from "../core/canonical.js";
|
|
24
|
+
import { isOAuthAuth, oauthOwner } from "../core/types.js";
|
|
25
|
+
import { ensureFreshToken } from "../lib/oauth/refresh.js";
|
|
26
|
+
import { classifyRefreshFailure, classifyStoredToken, FAILURE_STATUSES, remediationFor, } from "../lib/oauth/renew.js";
|
|
27
|
+
import { readToken } from "../lib/oauth/store.js";
|
|
28
|
+
/** Exported so tests and `doctor` reuse the exact iteration/scope rule
|
|
29
|
+
* without capturing stdout or re-deriving it (design.md D2's one-rule
|
|
30
|
+
* property). Read-only when `dryRun`: no network, no writes. */
|
|
31
|
+
export async function collectRenewEntries(opts) {
|
|
32
|
+
const homeDir = opts.homeDir ?? homedir();
|
|
33
|
+
const canonical = loadCanonicalSource(homeDir);
|
|
34
|
+
const names = Object.entries(canonical.mcp.servers)
|
|
35
|
+
.filter(([, def]) => isOAuthAuth(def.auth) && oauthOwner(def.auth) === "trellis")
|
|
36
|
+
.map(([name]) => name)
|
|
37
|
+
.sort();
|
|
38
|
+
const entries = [];
|
|
39
|
+
for (const name of names) {
|
|
40
|
+
entries.push(await renewOne(homeDir, name, opts));
|
|
41
|
+
}
|
|
42
|
+
return { entries, ok: entries.every((entry) => !FAILURE_STATUSES.has(entry.status)) };
|
|
43
|
+
}
|
|
44
|
+
async function renewOne(homeDir, name, opts) {
|
|
45
|
+
// The pre-refresh classification decides what `ensureFreshToken`'s
|
|
46
|
+
// return means: it only contacts the endpoint when the stored token was
|
|
47
|
+
// absent-but-not-required (returns undefined) or expiring (refreshes),
|
|
48
|
+
// so "expired-renewable before, token after" ⇒ a refresh just happened.
|
|
49
|
+
const before = classifyStoredToken(homeDir, name);
|
|
50
|
+
if (opts.dryRun)
|
|
51
|
+
return withDetails(homeDir, name, before);
|
|
52
|
+
try {
|
|
53
|
+
const token = await ensureFreshToken(homeDir, name, { ...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}) });
|
|
54
|
+
if (!token)
|
|
55
|
+
return withDetails(homeDir, name, "no-token");
|
|
56
|
+
return withDetails(homeDir, name, before === "expired-renewable" ? "refreshed" : "valid");
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
return withDetails(homeDir, name, classifyRefreshFailure(err));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function withDetails(homeDir, name, status) {
|
|
63
|
+
const expiresAt = readToken(homeDir, name)?.expiresAt;
|
|
64
|
+
return {
|
|
65
|
+
server: name,
|
|
66
|
+
status,
|
|
67
|
+
...(expiresAt !== undefined ? { expiresAt } : {}),
|
|
68
|
+
...(FAILURE_STATUSES.has(status) || status === "expired-renewable" ? { remediation: remediationFor(status, name) } : {}),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const STATUS_ICON = {
|
|
72
|
+
valid: "✅",
|
|
73
|
+
refreshed: "🔄",
|
|
74
|
+
"expired-renewable": "⏳",
|
|
75
|
+
"no-token": "❌",
|
|
76
|
+
"refresh-failed": "❌",
|
|
77
|
+
"needs-auth": "❌",
|
|
78
|
+
unreadable: "⚠️",
|
|
79
|
+
};
|
|
80
|
+
export async function runMcpRenew(opts) {
|
|
81
|
+
let result;
|
|
82
|
+
try {
|
|
83
|
+
result = await collectRenewEntries(opts);
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
87
|
+
return { exitCode: 1 };
|
|
88
|
+
}
|
|
89
|
+
if (opts.json) {
|
|
90
|
+
console.log(JSON.stringify(result, null, 2));
|
|
91
|
+
}
|
|
92
|
+
else if (result.entries.length === 0) {
|
|
93
|
+
console.log("No OAuth-classified MCP servers in canonical config — nothing to renew.");
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
for (const entry of result.entries) {
|
|
97
|
+
const expiry = entry.expiresAt !== undefined ? ` (expires ${new Date(entry.expiresAt).toISOString()})` : "";
|
|
98
|
+
console.log(`${STATUS_ICON[entry.status]} ${entry.server} — ${entry.status}${expiry}`);
|
|
99
|
+
if (entry.remediation)
|
|
100
|
+
console.log(` → ${entry.remediation}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { exitCode: result.ok ? 0 : 1 };
|
|
104
|
+
}
|
package/dist/core/canonical.d.ts
CHANGED
|
@@ -25,6 +25,7 @@ type McpServerDefYaml = Omit<McpServerDef, "staticEnv" | "envAliases" | "auth">
|
|
|
25
25
|
* them — a YAML cast is optimistic, validation is this module's job. */
|
|
26
26
|
type McpAuthYaml = string | {
|
|
27
27
|
kind?: unknown;
|
|
28
|
+
owner?: unknown;
|
|
28
29
|
client_id?: unknown;
|
|
29
30
|
client_secret_env?: unknown;
|
|
30
31
|
};
|
package/dist/core/canonical.js
CHANGED
|
@@ -25,7 +25,7 @@ function fromServerDefYaml(serverName, def) {
|
|
|
25
25
|
* `auth` translation and shape validation
|
|
26
26
|
* (trellis-mcp-oauth-static-client tasks 1.1/1.3): the scalar and the
|
|
27
27
|
* object form share one discriminator — `kind: oauth` — and the object
|
|
28
|
-
* accepts exactly `kind`, `client_id`, `client_secret_env`. Everything
|
|
28
|
+
* accepts exactly `kind`, `owner`, `client_id`, `client_secret_env`. Everything
|
|
29
29
|
* else is refused here at load, naming the field but never echoing the
|
|
30
30
|
* value: an invalid `client_secret_env` is at best a typo, at worst a
|
|
31
31
|
* pasted secret.
|
|
@@ -36,10 +36,10 @@ function fromAuthYaml(serverName, auth) {
|
|
|
36
36
|
if (typeof auth !== "object" || auth === null) {
|
|
37
37
|
throw new Error(`mcp/servers.yaml: "${serverName}" auth must be the scalar "oauth" or { kind: oauth, client_id?, client_secret_env? }`);
|
|
38
38
|
}
|
|
39
|
-
const { kind, client_id, client_secret_env, ...extra } = auth;
|
|
39
|
+
const { kind, owner, client_id, client_secret_env, ...extra } = auth;
|
|
40
40
|
const unknownKey = Object.keys(extra)[0];
|
|
41
41
|
if (unknownKey !== undefined) {
|
|
42
|
-
throw new Error(`mcp/servers.yaml: "${serverName}" auth has unrecognized key "${unknownKey}" — expected kind, client_id, client_secret_env`);
|
|
42
|
+
throw new Error(`mcp/servers.yaml: "${serverName}" auth has unrecognized key "${unknownKey}" — expected kind, owner, client_id, client_secret_env`);
|
|
43
43
|
}
|
|
44
44
|
if (kind !== "oauth") {
|
|
45
45
|
throw new Error(`mcp/servers.yaml: "${serverName}" auth.kind must be "oauth" — kind is the only recognized discriminator`);
|
|
@@ -51,6 +51,10 @@ function fromAuthYaml(serverName, auth) {
|
|
|
51
51
|
throw new Error(`mcp/servers.yaml: "${serverName}" auth.client_secret_env must be a variable NAME (e.g. FIGMA_CLIENT_SECRET), never a secret value`);
|
|
52
52
|
}
|
|
53
53
|
const out = { kind: "oauth" };
|
|
54
|
+
if (owner !== undefined && owner !== "agent" && owner !== "trellis")
|
|
55
|
+
throw new Error(`mcp/servers.yaml: "${serverName}" auth.owner must be agent or trellis`);
|
|
56
|
+
if (owner !== undefined)
|
|
57
|
+
out.owner = owner;
|
|
54
58
|
if (typeof client_id === "string")
|
|
55
59
|
out.clientId = client_id;
|
|
56
60
|
if (typeof client_secret_env === "string")
|
|
@@ -73,6 +77,7 @@ export function toServerDefYaml(def) {
|
|
|
73
77
|
? auth
|
|
74
78
|
: {
|
|
75
79
|
kind: auth.kind,
|
|
80
|
+
...(auth.owner !== undefined ? { owner: auth.owner } : {}),
|
|
76
81
|
...(auth.clientId !== undefined ? { client_id: auth.clientId } : {}),
|
|
77
82
|
...(auth.clientSecretEnv !== undefined ? { client_secret_env: auth.clientSecretEnv } : {}),
|
|
78
83
|
};
|
package/dist/core/types.d.ts
CHANGED
|
@@ -15,6 +15,8 @@ export type McpAuthMode = "oauth";
|
|
|
15
15
|
*/
|
|
16
16
|
export interface McpAuthConfig {
|
|
17
17
|
kind: McpAuthMode;
|
|
18
|
+
/** Omitted preserves each Agent's native authorization. */
|
|
19
|
+
owner?: "agent" | "trellis";
|
|
18
20
|
clientId?: string;
|
|
19
21
|
clientSecretEnv?: string;
|
|
20
22
|
}
|
|
@@ -23,6 +25,7 @@ export interface McpAuthConfig {
|
|
|
23
25
|
* object form exists — a strict-equality check would silently strip the
|
|
24
26
|
* OAuth routing from metadata-bearing servers. */
|
|
25
27
|
export declare function isOAuthAuth(auth: McpAuthMode | McpAuthConfig | undefined): boolean;
|
|
28
|
+
export declare function oauthOwner(auth: McpAuthMode | McpAuthConfig | undefined): "agent" | "trellis";
|
|
26
29
|
/** The pre-registered client metadata a classification carries. A scalar
|
|
27
30
|
* carries none — both forms route identically (design.md D1). */
|
|
28
31
|
export declare function oauthClientMetadata(auth: McpAuthMode | McpAuthConfig | undefined): {
|
|
@@ -53,7 +56,7 @@ export interface McpServerDef {
|
|
|
53
56
|
/** Explicit authorization classification. Absence means ordinary MCP;
|
|
54
57
|
* Trellis never infers OAuth from a URL or a transient 401. The scalar
|
|
55
58
|
* form and a metadata-less object are equivalent — metadata extends the
|
|
56
|
-
* authorization flow
|
|
59
|
+
* authorization flow; only explicit `owner: trellis` changes routing
|
|
57
60
|
* (trellis-mcp-oauth-static-client design.md D1). */
|
|
58
61
|
auth?: McpAuthMode | McpAuthConfig;
|
|
59
62
|
/** stdio only */
|
package/dist/core/types.js
CHANGED
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
export function isOAuthAuth(auth) {
|
|
12
12
|
return auth === "oauth" || (typeof auth === "object" && auth !== null && auth.kind === "oauth");
|
|
13
13
|
}
|
|
14
|
+
export function oauthOwner(auth) {
|
|
15
|
+
return typeof auth === "object" && auth?.owner === "trellis" ? "trellis" : "agent";
|
|
16
|
+
}
|
|
14
17
|
/** The pre-registered client metadata a classification carries. A scalar
|
|
15
18
|
* carries none — both forms route identically (design.md D1). */
|
|
16
19
|
export function oauthClientMetadata(auth) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CallToolResult, Tool } from "@modelcontextprotocol/
|
|
1
|
+
import type { CallToolResult, Tool } from "@modelcontextprotocol/server";
|
|
2
2
|
import type { RuntimeContext, TrellisProvider } from "./mcpRuntime.js";
|
|
3
3
|
/** Exposes every `agent-bridge.yaml` target — other than the calling
|
|
4
4
|
* Agent itself — as one MCP tool per target Agent. Routing which target
|
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
import type { McpServerDef, SecretsPolicy } from "../core/types.js";
|
|
19
19
|
import { type McpClientInfo } from "./mcpConnect.js";
|
|
20
20
|
import { type AggregatedTool } from "./mcpToolRegistry.js";
|
|
21
|
+
import type { Resource, ReadResourceResult } from "@modelcontextprotocol/client";
|
|
22
|
+
import type { SkillEntry } from "./skillManifest.js";
|
|
21
23
|
export interface GatewayBackend {
|
|
22
24
|
/** Every tool available through this backend, under its aggregated name. */
|
|
23
25
|
listTools(): Promise<AggregatedTool[]>;
|
|
@@ -25,6 +27,10 @@ export interface GatewayBackend {
|
|
|
25
27
|
callTool(name: string, args?: Record<string, unknown>): Promise<unknown>;
|
|
26
28
|
/** Sanitized connection state for user/Agent remediation. */
|
|
27
29
|
listStatus?(): readonly UpstreamStatus[];
|
|
30
|
+
listResources?(): Promise<Resource[]>;
|
|
31
|
+
readResource?(uri: string): Promise<ReadResourceResult>;
|
|
32
|
+
listSkills?(): Promise<SkillEntry[]>;
|
|
33
|
+
getSkill?(uri: string): Promise<SkillEntry | undefined>;
|
|
28
34
|
/** Release everything this backend holds. Must be safe to call twice. */
|
|
29
35
|
close(): Promise<void>;
|
|
30
36
|
}
|
|
@@ -39,6 +45,11 @@ export interface UpstreamStatus {
|
|
|
39
45
|
name: string;
|
|
40
46
|
transport: "stdio" | "http" | "sse";
|
|
41
47
|
status: UpstreamStatusKind;
|
|
48
|
+
/** How many tools this upstream contributed at connect time — the
|
|
49
|
+
* structured form of the count `detail` already carries in prose, so
|
|
50
|
+
* Agents reasoning about capability never parse a sentence
|
|
51
|
+
* (trellis-mcp-status-credential-health design.md D5). */
|
|
52
|
+
toolCount?: number;
|
|
42
53
|
detail?: string;
|
|
43
54
|
remediation?: string;
|
|
44
55
|
remediationKind?: "oauth" | "environment" | "install" | "endpoint" | "router";
|
|
@@ -79,11 +90,16 @@ export declare class LocalBackend implements GatewayBackend {
|
|
|
79
90
|
private readonly connected;
|
|
80
91
|
private readonly statuses;
|
|
81
92
|
private closed;
|
|
93
|
+
private readonly resources;
|
|
82
94
|
private constructor();
|
|
83
95
|
static connect(upstreams: readonly UpstreamSpec[], options: LocalBackendOptions): Promise<LocalBackend>;
|
|
84
96
|
private addUpstream;
|
|
85
97
|
listTools(): Promise<AggregatedTool[]>;
|
|
86
98
|
callTool(name: string, args?: Record<string, unknown>): Promise<unknown>;
|
|
99
|
+
listResources(): Promise<Resource[]>;
|
|
100
|
+
readResource(uri: string): Promise<ReadResourceResult>;
|
|
101
|
+
listSkills(): Promise<SkillEntry[]>;
|
|
102
|
+
getSkill(uri: string): Promise<SkillEntry | undefined>;
|
|
87
103
|
listStatus(): readonly UpstreamStatus[];
|
|
88
104
|
close(): Promise<void>;
|
|
89
105
|
private closeUpstream;
|
|
@@ -15,10 +15,13 @@
|
|
|
15
15
|
* standard `Client` rather than something with its own shape to maintain
|
|
16
16
|
* (D12).
|
|
17
17
|
*/
|
|
18
|
+
import { isOAuthAuth, oauthOwner } from "../core/types.js";
|
|
18
19
|
import { connectServer } from "./mcpConnect.js";
|
|
19
20
|
import { withTimeout } from "./mcpConnect.js";
|
|
20
21
|
import { ensureFreshToken } from "./oauth/refresh.js";
|
|
22
|
+
import { readToken } from "./oauth/store.js";
|
|
21
23
|
import { McpToolRegistry } from "./mcpToolRegistry.js";
|
|
24
|
+
import { UpstreamResources } from "./upstreamResources.js";
|
|
22
25
|
/**
|
|
23
26
|
* v1's backend: connects each upstream itself and aggregates the result.
|
|
24
27
|
*
|
|
@@ -34,12 +37,14 @@ export class LocalBackend {
|
|
|
34
37
|
connected = new Set();
|
|
35
38
|
statuses = new Map();
|
|
36
39
|
closed = false;
|
|
37
|
-
|
|
40
|
+
resources;
|
|
41
|
+
constructor(warn, timeoutMs) {
|
|
38
42
|
this.warn = warn;
|
|
43
|
+
this.resources = new UpstreamResources(timeoutMs, warn);
|
|
39
44
|
}
|
|
40
45
|
static async connect(upstreams, options) {
|
|
41
46
|
const warn = options.onWarning ?? ((message) => console.error(message));
|
|
42
|
-
const backend = new LocalBackend(warn);
|
|
47
|
+
const backend = new LocalBackend(warn, options.connectTimeoutMs);
|
|
43
48
|
await Promise.all(upstreams.map((upstream) => backend.addUpstream(upstream, options)));
|
|
44
49
|
return backend;
|
|
45
50
|
}
|
|
@@ -62,7 +67,7 @@ export class LocalBackend {
|
|
|
62
67
|
}
|
|
63
68
|
let client;
|
|
64
69
|
try {
|
|
65
|
-
client =
|
|
70
|
+
client = await connectServer(effectiveDef, options.secretsPolicy, options.connectTimeoutMs, options.clientInfo);
|
|
66
71
|
}
|
|
67
72
|
catch (err) {
|
|
68
73
|
this.warn(`trellis-mcp-gateway: failed to connect to MCP server "${name}": ${errorText(err)}`);
|
|
@@ -72,7 +77,9 @@ export class LocalBackend {
|
|
|
72
77
|
this.connected.add(client);
|
|
73
78
|
let tools;
|
|
74
79
|
try {
|
|
75
|
-
const result =
|
|
80
|
+
const result = client.getServerCapabilities()?.tools
|
|
81
|
+
? await withTimeout(client.listTools(), options.connectTimeoutMs, `listTools timed out after ${options.connectTimeoutMs}ms`)
|
|
82
|
+
: { tools: [] };
|
|
76
83
|
tools = result.tools;
|
|
77
84
|
}
|
|
78
85
|
catch (err) {
|
|
@@ -82,6 +89,7 @@ export class LocalBackend {
|
|
|
82
89
|
return;
|
|
83
90
|
}
|
|
84
91
|
const { skipped } = this.registry.add(name, client, tools);
|
|
92
|
+
this.resources.add(name, client);
|
|
85
93
|
for (const collided of skipped) {
|
|
86
94
|
this.warn(`trellis-mcp-gateway: tool "${collided}" from server "${name}" collides with an already-registered tool and was skipped`);
|
|
87
95
|
}
|
|
@@ -90,6 +98,7 @@ export class LocalBackend {
|
|
|
90
98
|
name,
|
|
91
99
|
transport,
|
|
92
100
|
status: "degraded",
|
|
101
|
+
toolCount: 0,
|
|
93
102
|
detail: "router connected but exposed no downstream tools",
|
|
94
103
|
remediationKind: "router",
|
|
95
104
|
requiresHuman: true,
|
|
@@ -98,7 +107,7 @@ export class LocalBackend {
|
|
|
98
107
|
});
|
|
99
108
|
}
|
|
100
109
|
else {
|
|
101
|
-
this.statuses.set(name, { name, transport, status: "ready", detail: `connected; ${tools.length} tool(s) available` });
|
|
110
|
+
this.statuses.set(name, { name, transport, status: "ready", toolCount: tools.length, detail: `connected; ${tools.length} tool(s) available` });
|
|
102
111
|
}
|
|
103
112
|
}
|
|
104
113
|
async listTools() {
|
|
@@ -107,6 +116,10 @@ export class LocalBackend {
|
|
|
107
116
|
async callTool(name, args) {
|
|
108
117
|
return this.registry.callTool(name, args);
|
|
109
118
|
}
|
|
119
|
+
listResources() { return this.resources.listResources(); }
|
|
120
|
+
readResource(uri) { return this.resources.readResource(uri); }
|
|
121
|
+
listSkills() { return this.resources.listSkills(); }
|
|
122
|
+
getSkill(uri) { return this.resources.getSkill(uri); }
|
|
110
123
|
listStatus() {
|
|
111
124
|
return [...this.statuses.values()];
|
|
112
125
|
}
|
|
@@ -189,9 +202,16 @@ function failureStatus(name, def, error) {
|
|
|
189
202
|
* the kind of invisible precedence that is impossible to debug.
|
|
190
203
|
*/
|
|
191
204
|
export async function withOAuthHeader(name, def, homeDir) {
|
|
205
|
+
if (!isOAuthAuth(def.auth) || oauthOwner(def.auth) !== "trellis")
|
|
206
|
+
return def;
|
|
192
207
|
const hasExplicitAuth = Object.keys(def.headers ?? {}).some((key) => key.toLowerCase() === "authorization");
|
|
193
208
|
if (hasExplicitAuth)
|
|
194
209
|
return def;
|
|
210
|
+
const stored = readToken(homeDir, name);
|
|
211
|
+
const resource = new URL(def.url);
|
|
212
|
+
resource.hash = "";
|
|
213
|
+
if (stored?.resourceUrl && stored.resourceUrl !== resource.toString())
|
|
214
|
+
throw new Error("OAuth authorization required: saved credential targets another MCP resource");
|
|
195
215
|
const token = await ensureFreshToken(homeDir, name);
|
|
196
216
|
if (!token)
|
|
197
217
|
return def;
|
package/dist/lib/mcpConnect.d.ts
CHANGED
|
@@ -12,8 +12,7 @@
|
|
|
12
12
|
* which servers are in scope, or knows what a tool is; callers isolate
|
|
13
13
|
* failures in whatever way suits them.
|
|
14
14
|
*/
|
|
15
|
-
import { Client } from "@modelcontextprotocol/
|
|
16
|
-
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
15
|
+
import { Client } from "@modelcontextprotocol/client";
|
|
17
16
|
import type { McpServerDef, SecretsPolicy } from "../core/types.js";
|
|
18
17
|
/** What an MCP upstream is told this client is. Each consumer passes its
|
|
19
18
|
* own so an upstream's logs name the thing that actually connected. */
|
|
@@ -40,7 +39,11 @@ export declare function withTimeout<T>(promise: Promise<T>, timeoutMs: number, m
|
|
|
40
39
|
* on close, but the original connect failure is what the caller needs
|
|
41
40
|
* to see, not a secondary cleanup error.
|
|
42
41
|
*/
|
|
43
|
-
export declare function connectWithCleanup
|
|
42
|
+
export declare function connectWithCleanup<T, TTransport extends {
|
|
43
|
+
close(): Promise<void>;
|
|
44
|
+
}>(client: T & {
|
|
45
|
+
connect(transport: TTransport): Promise<unknown>;
|
|
46
|
+
}, transport: TTransport, timeoutMs: number, message: string): Promise<T>;
|
|
44
47
|
export declare function connectStdio(def: McpServerDef, secretsPolicy: SecretsPolicy, timeoutMs: number, clientInfo: McpClientInfo): Promise<Client>;
|
|
45
48
|
export declare function resolveHeaders(def: McpServerDef, secretsPolicy: SecretsPolicy): Record<string, string> | undefined;
|
|
46
49
|
export declare function connectHttp(def: McpServerDef, secretsPolicy: SecretsPolicy, timeoutMs: number, clientInfo: McpClientInfo): Promise<Client>;
|
package/dist/lib/mcpConnect.js
CHANGED
|
@@ -12,10 +12,8 @@
|
|
|
12
12
|
* which servers are in scope, or knows what a tool is; callers isolate
|
|
13
13
|
* failures in whatever way suits them.
|
|
14
14
|
*/
|
|
15
|
-
import { Client } from "@modelcontextprotocol/
|
|
16
|
-
import { StdioClientTransport, getDefaultEnvironment } from "@modelcontextprotocol/
|
|
17
|
-
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
18
|
-
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
15
|
+
import { Client, StreamableHTTPClientTransport, SSEClientTransport } from "@modelcontextprotocol/client";
|
|
16
|
+
import { StdioClientTransport, getDefaultEnvironment } from "@modelcontextprotocol/client/stdio";
|
|
19
17
|
import { resolveSecretEnv } from "./secretEnv.js";
|
|
20
18
|
import { extractTemplateVarNames } from "./envVarNames.js";
|
|
21
19
|
export const DEFAULT_CONNECT_TIMEOUT_MS = 10_000;
|
|
@@ -68,7 +66,7 @@ export async function connectWithCleanup(client, transport, timeoutMs, message)
|
|
|
68
66
|
}
|
|
69
67
|
}
|
|
70
68
|
export async function connectStdio(def, secretsPolicy, timeoutMs, clientInfo) {
|
|
71
|
-
const client = new Client(clientInfo, { capabilities: {} });
|
|
69
|
+
const client = new Client(clientInfo, { capabilities: {}, versionNegotiation: { mode: "auto", probe: { timeoutMs: Math.min(3000, timeoutMs / 2) } } });
|
|
72
70
|
const envAliases = def.envAliases ?? {};
|
|
73
71
|
// `envAliases`' values are source variable names — resolved through the
|
|
74
72
|
// exact same call as `env`'s own names, never delivered as the raw
|
|
@@ -81,6 +79,9 @@ export async function connectStdio(def, secretsPolicy, timeoutMs, clientInfo) {
|
|
|
81
79
|
const transport = new StdioClientTransport({
|
|
82
80
|
command: def.command,
|
|
83
81
|
args: def.args,
|
|
82
|
+
// A 16 MiB skill file can expand sixfold when JSON escapes control bytes.
|
|
83
|
+
// Keep framing bounded without rejecting a valid Skills-extension file.
|
|
84
|
+
maxBufferSize: 128 * 1024 * 1024,
|
|
84
85
|
// staticEnv merges last: a literal, intentionally-plain value (an
|
|
85
86
|
// email, an environment tag) always wins over an unresolved name-only
|
|
86
87
|
// entry's empty-string fallback for the same key — though in practice
|
|
@@ -102,12 +103,13 @@ export function resolveHeaders(def, secretsPolicy) {
|
|
|
102
103
|
return result;
|
|
103
104
|
}
|
|
104
105
|
export async function connectHttp(def, secretsPolicy, timeoutMs, clientInfo) {
|
|
105
|
-
const client = new Client(clientInfo, { capabilities: {} });
|
|
106
|
+
const client = new Client(clientInfo, { capabilities: {}, versionNegotiation: { mode: "auto", probe: { timeoutMs } } });
|
|
106
107
|
const headers = resolveHeaders(def, secretsPolicy);
|
|
107
108
|
const opts = headers ? { requestInit: { headers } } : undefined;
|
|
108
109
|
return connectWithCleanup(client, new StreamableHTTPClientTransport(new URL(def.url), opts), timeoutMs, `connect timed out after ${timeoutMs}ms`);
|
|
109
110
|
}
|
|
110
111
|
export async function connectSse(def, secretsPolicy, timeoutMs, clientInfo) {
|
|
112
|
+
// HTTP+SSE is a legacy transport; it cannot carry modern discovery.
|
|
111
113
|
const client = new Client(clientInfo, { capabilities: {} });
|
|
112
114
|
const headers = resolveHeaders(def, secretsPolicy);
|
|
113
115
|
const opts = headers ? { requestInit: { headers } } : undefined;
|
package/dist/lib/mcpRuntime.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Server } from "@modelcontextprotocol/
|
|
2
|
-
import type { CallToolResult, GetPromptResult, Implementation, Prompt, ReadResourceResult, Resource, Tool } from "@modelcontextprotocol/
|
|
1
|
+
import { Server } from "@modelcontextprotocol/server";
|
|
2
|
+
import type { CallToolResult, GetPromptResult, Implementation, Prompt, ReadResourceResult, Resource, Tool } from "@modelcontextprotocol/server";
|
|
3
3
|
import type { AgentId } from "../core/types.js";
|
|
4
4
|
import type { GatewayBackend } from "./gatewayBackend.js";
|
|
5
5
|
import type { SkillEntry } from "./skillManifest.js";
|
|
@@ -47,6 +47,10 @@ export declare class UpstreamProvider implements TrellisProvider {
|
|
|
47
47
|
constructor(backend: GatewayBackend);
|
|
48
48
|
listTools(): Promise<Tool[]>;
|
|
49
49
|
callTool(name: string, args: unknown): Promise<CallToolResult>;
|
|
50
|
+
listResources(): Promise<Resource[]>;
|
|
51
|
+
readResource(uri: URL): Promise<ReadResourceResult>;
|
|
52
|
+
listSkillEntries(): Promise<SkillEntry[]>;
|
|
53
|
+
getSkillEntry(uri: string): Promise<SkillEntry | undefined>;
|
|
50
54
|
close(): Promise<void>;
|
|
51
55
|
}
|
|
52
56
|
export declare function createRuntimeServer(serverInfo: Implementation, context: RuntimeContext, registry: BuiltinRegistry): Server;
|
package/dist/lib/mcpRuntime.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { Server } from "@modelcontextprotocol/
|
|
2
|
-
import {
|
|
1
|
+
import { Server, ProtocolErrorCode, ProtocolError } from "@modelcontextprotocol/server";
|
|
2
|
+
import { z } from "zod/v4";
|
|
3
3
|
import { allocateExposedToolNames } from "./mcpToolRegistry.js";
|
|
4
|
+
import { withoutUpstreamServerInfo } from "./upstreamResources.js";
|
|
4
5
|
function compactBuiltinToolName(name) {
|
|
5
6
|
return name.startsWith("trellis.") ? name.slice("trellis.".length).replaceAll(".", "_") : name;
|
|
6
7
|
}
|
|
@@ -102,6 +103,9 @@ export class BuiltinRegistry {
|
|
|
102
103
|
await this.listResources(context);
|
|
103
104
|
provider = this.resourceOwners.get(key);
|
|
104
105
|
}
|
|
106
|
+
// A manifest can introduce supporting files not returned by resources/list.
|
|
107
|
+
if (!provider && uri.protocol === "trellis-upstream:")
|
|
108
|
+
provider = this.providers.find((candidate) => candidate.id === "upstream");
|
|
105
109
|
if (!provider?.readResource)
|
|
106
110
|
throw new Error(`unknown Trellis resource "${key}"`);
|
|
107
111
|
return provider.readResource(uri, context);
|
|
@@ -190,8 +194,16 @@ export class UpstreamProvider {
|
|
|
190
194
|
return (await this.backend.listTools());
|
|
191
195
|
}
|
|
192
196
|
async callTool(name, args) {
|
|
193
|
-
return (await this.backend.callTool(name, args));
|
|
197
|
+
return withoutUpstreamServerInfo((await this.backend.callTool(name, args)));
|
|
194
198
|
}
|
|
199
|
+
async listResources() { return await this.backend.listResources?.() ?? []; }
|
|
200
|
+
async readResource(uri) {
|
|
201
|
+
if (!this.backend.readResource)
|
|
202
|
+
throw new Error("upstream resources unavailable");
|
|
203
|
+
return this.backend.readResource(uri.toString());
|
|
204
|
+
}
|
|
205
|
+
async listSkillEntries() { return await this.backend.listSkills?.() ?? []; }
|
|
206
|
+
async getSkillEntry(uri) { return this.backend.getSkill?.(uri); }
|
|
195
207
|
async close() {
|
|
196
208
|
await this.backend.close();
|
|
197
209
|
}
|
|
@@ -202,35 +214,33 @@ export function createRuntimeServer(serverInfo, context, registry) {
|
|
|
202
214
|
tools: { listChanged: true },
|
|
203
215
|
resources: { listChanged: true },
|
|
204
216
|
prompts: { listChanged: true },
|
|
217
|
+
extensions: { "io.modelcontextprotocol/skills": {} },
|
|
205
218
|
},
|
|
206
219
|
});
|
|
207
|
-
server.setRequestHandler(
|
|
208
|
-
server.setRequestHandler(
|
|
209
|
-
server.setRequestHandler(
|
|
210
|
-
server.setRequestHandler(
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
// `skills/list` and `skills/get` are not in this SDK's schema set (it speaks
|
|
214
|
-
// at most 2025-11-25), so they are answered through the SDK's fallback for
|
|
215
|
-
// methods with no registered handler, which keeps the extension free of a new
|
|
216
|
-
// direct zod dependency. The extension itself is deliberately NOT declared in
|
|
217
|
-
// the capabilities above: the specification declares it only through
|
|
218
|
-
// `server/discover`, which this revision lacks, and inventing another field
|
|
219
|
-
// would claim a protocol nobody defined (trellis-skills-over-mcp D4). Any
|
|
220
|
-
// other unknown method keeps the SDK's usual "method not found".
|
|
221
|
-
server.fallbackRequestHandler = async (request) => {
|
|
222
|
-
const cache = { resultType: "complete", ttlMs: 0, cacheScope: "private" };
|
|
223
|
-
if (request.method === "skills/list") {
|
|
224
|
-
return { ...cache, skills: await registry.listSkills(context) };
|
|
220
|
+
server.setRequestHandler("tools/list", async () => ({ tools: await registry.listTools(context) }));
|
|
221
|
+
server.setRequestHandler("tools/call", async ({ params }) => registry.callTool(params.name, params.arguments, context));
|
|
222
|
+
server.setRequestHandler("resources/list", async () => ({ resources: await registry.listResources(context) }));
|
|
223
|
+
server.setRequestHandler("resources/read", async ({ params }) => {
|
|
224
|
+
try {
|
|
225
|
+
return await registry.readResource(new URL(params.uri), context);
|
|
225
226
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
return { ...cache, skill: entry };
|
|
227
|
+
catch (err) {
|
|
228
|
+
if (err instanceof TypeError || (err instanceof Error && /unknown|not found|not allowed|outside|secret-like|invalid.*uri/i.test(err.message))) {
|
|
229
|
+
throw new ProtocolError(ProtocolErrorCode.InvalidParams, "unknown or unreadable resource");
|
|
230
|
+
}
|
|
231
|
+
throw err;
|
|
232
232
|
}
|
|
233
|
-
|
|
234
|
-
};
|
|
233
|
+
});
|
|
234
|
+
server.setRequestHandler("prompts/list", async () => ({ prompts: await registry.listPrompts(context) }));
|
|
235
|
+
server.setRequestHandler("prompts/get", async ({ params }) => registry.getPrompt(params.name, params.arguments, context));
|
|
236
|
+
const result = z.looseObject({});
|
|
237
|
+
const cache = { ttlMs: 0, cacheScope: "private" };
|
|
238
|
+
server.setRequestHandler("skills/list", { params: z.object({ cursor: z.string().optional() }), result }, async () => ({ ...cache, skills: await registry.listSkills(context) }));
|
|
239
|
+
server.setRequestHandler("skills/get", { params: z.object({ uri: z.string() }), result }, async ({ uri }) => {
|
|
240
|
+
const entry = await registry.getSkill(uri, context);
|
|
241
|
+
if (!entry)
|
|
242
|
+
throw new ProtocolError(ProtocolErrorCode.InvalidParams, "unknown skill");
|
|
243
|
+
return { ...cache, skill: entry };
|
|
244
|
+
});
|
|
235
245
|
return server;
|
|
236
246
|
}
|
|
@@ -1,10 +1,44 @@
|
|
|
1
|
-
import type { CallToolResult, Tool } from "@modelcontextprotocol/
|
|
1
|
+
import type { CallToolResult, Tool } from "@modelcontextprotocol/server";
|
|
2
2
|
import type { GatewayBackend } from "./gatewayBackend.js";
|
|
3
3
|
import type { RuntimeContext, TrellisProvider } from "./mcpRuntime.js";
|
|
4
|
+
import { type OAuthRenewStatus } from "./oauth/renew.js";
|
|
5
|
+
export interface McpStatusProviderOptions {
|
|
6
|
+
/** Where the OAuth token store lives. Omitted, the `oauth` section of
|
|
7
|
+
* the report is empty — the provider stays usable by callers with no
|
|
8
|
+
* home directory, the same contract `LocalBackend` exposes. */
|
|
9
|
+
homeDir?: string;
|
|
10
|
+
/** OAuth-classified servers in scope for this agent, with their
|
|
11
|
+
* ownership (trellis-desktop-oauth-ownership). Trellis-owned servers
|
|
12
|
+
* connect through the gateway with a Trellis-store token, so their
|
|
13
|
+
* stored-credential health is meaningful and reported. Agent-owned
|
|
14
|
+
* servers are direct native entries authorized by the Agent's own
|
|
15
|
+
* client — reported as `agent-managed` precisely because inferring
|
|
16
|
+
* their state from Trellis's store would be a lie (that change's
|
|
17
|
+
* "unknown native authorization is displayed as unknown"). */
|
|
18
|
+
oauthServers?: readonly OAuthServerRef[];
|
|
19
|
+
}
|
|
20
|
+
export interface OAuthServerRef {
|
|
21
|
+
name: string;
|
|
22
|
+
owner: "agent" | "trellis";
|
|
23
|
+
}
|
|
24
|
+
/** One OAuth-classified server's credential health. `credential` — not
|
|
25
|
+
* `status` — because the gateway cannot observe a direct-routed server's
|
|
26
|
+
* connection state; claiming "ready" would be a lie
|
|
27
|
+
* (trellis-mcp-status-credential-health design.md D1). */
|
|
28
|
+
export interface OAuthCredentialReport {
|
|
29
|
+
name: string;
|
|
30
|
+
owner: "agent" | "trellis";
|
|
31
|
+
credential: OAuthRenewStatus | "agent-managed";
|
|
32
|
+
/** ISO 8601, when the stored token carries an expiry at all. */
|
|
33
|
+
expiresAt?: string;
|
|
34
|
+
/** Present only when there is an action for Trellis to take. */
|
|
35
|
+
remediation?: string;
|
|
36
|
+
}
|
|
4
37
|
export declare class McpStatusProvider implements TrellisProvider {
|
|
5
38
|
private readonly backend;
|
|
39
|
+
private readonly options;
|
|
6
40
|
readonly id = "mcp";
|
|
7
|
-
constructor(backend: GatewayBackend);
|
|
41
|
+
constructor(backend: GatewayBackend, options?: McpStatusProviderOptions);
|
|
8
42
|
listTools(): Tool[];
|
|
9
43
|
callTool(name: string, _args: unknown, _context: RuntimeContext): Promise<CallToolResult>;
|
|
10
44
|
}
|