agent-trellis 0.10.0 → 0.12.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 +30 -5
- package/dist/commands/mcp.d.ts +71 -0
- package/dist/commands/mcp.js +145 -4
- package/dist/commands/mcpAuth.d.ts +6 -0
- package/dist/commands/mcpAuth.js +38 -10
- package/dist/commands/mcpGateway.js +7 -7
- package/dist/commands/skill.d.ts +45 -1
- package/dist/commands/skill.js +83 -2
- 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 +11 -0
- package/dist/lib/gatewayBackend.js +23 -4
- package/dist/lib/mcpConnect.d.ts +6 -3
- package/dist/lib/mcpConnect.js +8 -6
- package/dist/lib/mcpRuntime.d.ts +12 -2
- package/dist/lib/mcpRuntime.js +70 -9
- package/dist/lib/mcpStatusProvider.d.ts +1 -1
- package/dist/lib/memoryProvider.d.ts +1 -1
- 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 +48 -11
- package/dist/lib/oauth/refresh.d.ts +1 -0
- package/dist/lib/oauth/refresh.js +3 -1
- package/dist/lib/oauth/store.d.ts +2 -0
- package/dist/lib/runtimeControlProvider.d.ts +1 -1
- package/dist/lib/scopeSelection.d.ts +36 -0
- package/dist/lib/scopeSelection.js +65 -0
- package/dist/lib/skillManifest.d.ts +66 -0
- package/dist/lib/skillManifest.js +203 -0
- package/dist/lib/skillProvider.d.ts +21 -1
- package/dist/lib/skillProvider.js +61 -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 +37906 -19932
- package/docs/architecture.md +14 -13
- package/docs/mcp-authorization-ownership.md +59 -0
- package/docs/skills-over-mcp.md +91 -0
- package/package.json +6 -3
package/dist/commands/skill.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `trellis skill list|add|remove` — command-line CRUD for canonical
|
|
2
|
+
* `trellis skill list|add|remove|scope` — command-line CRUD for canonical
|
|
3
3
|
* skills (trellis-canonical-cli-crud), an alternative to hand-editing
|
|
4
4
|
* `~/.trellis/skills/<name>/SKILL.md` directly. `add`'s conflict
|
|
5
5
|
* decision and `remove`'s "sync will un-sync it" behavior both reuse
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import type { AgentId } from "../core/types.js";
|
|
11
11
|
import { type BackupSession } from "../lib/backup.js";
|
|
12
12
|
import type { SyncReport } from "./sync.js";
|
|
13
|
+
import { type ScopeSelectorRaw } from "../lib/scopeSelection.js";
|
|
13
14
|
import type { RemoteSkillLockEntry } from "../lib/remoteSkillLock.js";
|
|
14
15
|
export type BuiltinSkillUpdateAction = "create" | "update" | "already-current";
|
|
15
16
|
export interface BuiltinSkillUpdatePlan {
|
|
@@ -97,3 +98,46 @@ export declare function runSkillRemove(name: string, opts?: {
|
|
|
97
98
|
}): Promise<{
|
|
98
99
|
exitCode: number;
|
|
99
100
|
}>;
|
|
101
|
+
export type SkillScopeAction = "updated" | "already-set" | "not-found" | "invalid-input";
|
|
102
|
+
export interface SkillScopePlan {
|
|
103
|
+
name: string;
|
|
104
|
+
action: SkillScopeAction;
|
|
105
|
+
detail: string;
|
|
106
|
+
/** What will be recorded in `scope.yaml`; `undefined` clears the explicit
|
|
107
|
+
* scope so the skill reaches every managed agent. Set when the selection was
|
|
108
|
+
* valid, whatever the action. */
|
|
109
|
+
scope?: AgentId[];
|
|
110
|
+
mode?: "all" | "none" | "explicit";
|
|
111
|
+
/** True when a full selection was dropped instead of recorded
|
|
112
|
+
* (trellis-scope-editing-and-auth-status design.md D3). */
|
|
113
|
+
normalizedFromFull?: boolean;
|
|
114
|
+
/** The agents that will actually receive the skill afterwards. */
|
|
115
|
+
effective?: readonly AgentId[];
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* `skill scope <name> --agents a,b | --all | --none`
|
|
119
|
+
* (trellis-scope-editing-and-auth-status tasks.md 1.1). Plan only — the write
|
|
120
|
+
* and the cascade sync live in `applySkillScopeWithSync`.
|
|
121
|
+
*/
|
|
122
|
+
export declare function collectSkillScopePlan(name: string | undefined, raw: ScopeSelectorRaw, homeDir?: string): SkillScopePlan;
|
|
123
|
+
export interface SkillScopeOutcome {
|
|
124
|
+
plan: SkillScopePlan;
|
|
125
|
+
writeError?: string;
|
|
126
|
+
sync?: SyncReport;
|
|
127
|
+
}
|
|
128
|
+
/** Write inside a backup session so `trellis rollback` covers it, then the
|
|
129
|
+
* same skills sync `add` / `remove` run — narrowing is converged by
|
|
130
|
+
* `symlinkPlan`'s existing "canonical entry gone or scoped away" removal
|
|
131
|
+
* (design.md D5). */
|
|
132
|
+
export declare function applySkillScopeWithSync(plan: SkillScopePlan, opts?: {
|
|
133
|
+
homeDir?: string;
|
|
134
|
+
dryRun?: boolean;
|
|
135
|
+
backupSession?: BackupSession;
|
|
136
|
+
}): Promise<SkillScopeOutcome>;
|
|
137
|
+
export declare function runSkillScope(name: string | undefined, raw: ScopeSelectorRaw, opts?: {
|
|
138
|
+
homeDir?: string;
|
|
139
|
+
json?: boolean;
|
|
140
|
+
dryRun?: boolean;
|
|
141
|
+
}): Promise<{
|
|
142
|
+
exitCode: number;
|
|
143
|
+
}>;
|
package/dist/commands/skill.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `trellis skill list|add|remove` — command-line CRUD for canonical
|
|
2
|
+
* `trellis skill list|add|remove|scope` — command-line CRUD for canonical
|
|
3
3
|
* skills (trellis-canonical-cli-crud), an alternative to hand-editing
|
|
4
4
|
* `~/.trellis/skills/<name>/SKILL.md` directly. `add`'s conflict
|
|
5
5
|
* decision and `remove`'s "sync will un-sync it" behavior both reuse
|
|
@@ -14,12 +14,13 @@ import { dirname } from "node:path";
|
|
|
14
14
|
import { fileURLToPath } from "node:url";
|
|
15
15
|
import { decideDirImport } from "../lib/dirEquals.js";
|
|
16
16
|
import { findSkillFile } from "../lib/skillFile.js";
|
|
17
|
-
import { loadCanonicalSource } from "../core/canonical.js";
|
|
17
|
+
import { loadCanonicalSource, writeSkillScopeYaml } from "../core/canonical.js";
|
|
18
18
|
import { resolveScope } from "../core/types.js";
|
|
19
19
|
import { isBuiltinSkillName } from "../lib/builtinSkills.js";
|
|
20
20
|
import { openBackupSession } from "../lib/backup.js";
|
|
21
21
|
import { collectSyncReport, printReport as printSyncReport } from "./sync.js";
|
|
22
22
|
import { remoteSkillProvenance } from "./remoteSkill.js";
|
|
23
|
+
import { describeScope, resolveScopeSelection, sameScope } from "../lib/scopeSelection.js";
|
|
23
24
|
function builtinSkillTemplate() {
|
|
24
25
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
25
26
|
return readFileSync(join(here, "..", "..", "schema", "builtin-skills", "trellis-runtime", "SKILL.md"), "utf8");
|
|
@@ -205,3 +206,83 @@ export async function runSkillRemove(name, opts = {}) {
|
|
|
205
206
|
const syncConflict = syncReport?.reports.some((r) => r.items.some((i) => i.action === "conflict")) ?? false;
|
|
206
207
|
return { exitCode: failed || syncConflict ? 1 : 0 };
|
|
207
208
|
}
|
|
209
|
+
/**
|
|
210
|
+
* `skill scope <name> --agents a,b | --all | --none`
|
|
211
|
+
* (trellis-scope-editing-and-auth-status tasks.md 1.1). Plan only — the write
|
|
212
|
+
* and the cascade sync live in `applySkillScopeWithSync`.
|
|
213
|
+
*/
|
|
214
|
+
export function collectSkillScopePlan(name, raw, homeDir = homedir()) {
|
|
215
|
+
if (!name)
|
|
216
|
+
return { name: "(none)", action: "invalid-input", detail: "usage: trellis skill scope <name> --agents <ids> | --all | --none" };
|
|
217
|
+
if (isBuiltinSkillName(name)) {
|
|
218
|
+
return { name, action: "invalid-input", detail: `"${name}" is a Trellis package-owned Skill — its scope is not editable` };
|
|
219
|
+
}
|
|
220
|
+
const canonical = loadCanonicalSource(homeDir);
|
|
221
|
+
const selection = resolveScopeSelection(raw, canonical.managedAgents);
|
|
222
|
+
if (!selection.ok)
|
|
223
|
+
return { name, action: "invalid-input", detail: selection.detail };
|
|
224
|
+
const skill = canonical.skills.find((candidate) => candidate.name === name);
|
|
225
|
+
if (!skill)
|
|
226
|
+
return { name, action: "not-found", detail: `no canonical skill named "${name}"` };
|
|
227
|
+
const base = {
|
|
228
|
+
name,
|
|
229
|
+
scope: selection.scope,
|
|
230
|
+
mode: selection.mode,
|
|
231
|
+
normalizedFromFull: selection.normalizedFromFull,
|
|
232
|
+
effective: resolveScope(selection.scope, canonical.managedAgents),
|
|
233
|
+
};
|
|
234
|
+
const summary = describeScope(selection.scope, canonical.managedAgents);
|
|
235
|
+
const normalized = selection.normalizedFromFull ? " (every managed agent selected — recorded as the default, so a newly managed agent is included)" : "";
|
|
236
|
+
if (sameScope(skill.scope, selection.scope)) {
|
|
237
|
+
return { ...base, action: "already-set", detail: `scope is already ${summary}` };
|
|
238
|
+
}
|
|
239
|
+
return { ...base, action: "updated", detail: `set scope to ${summary}${normalized}` };
|
|
240
|
+
}
|
|
241
|
+
/** Write inside a backup session so `trellis rollback` covers it, then the
|
|
242
|
+
* same skills sync `add` / `remove` run — narrowing is converged by
|
|
243
|
+
* `symlinkPlan`'s existing "canonical entry gone or scoped away" removal
|
|
244
|
+
* (design.md D5). */
|
|
245
|
+
export async function applySkillScopeWithSync(plan, opts = {}) {
|
|
246
|
+
const homeDir = opts.homeDir ?? homedir();
|
|
247
|
+
const ownSession = !opts.dryRun && !opts.backupSession && plan.action === "updated" ? openBackupSession(homeDir, "skill-scope") : undefined;
|
|
248
|
+
const session = opts.backupSession ?? ownSession;
|
|
249
|
+
let writeError;
|
|
250
|
+
if (!opts.dryRun && plan.action === "updated") {
|
|
251
|
+
const result = writeSkillScopeYaml(join(homeDir, ".trellis", "scope.yaml"), plan.name, plan.scope, session);
|
|
252
|
+
if (!result.ok)
|
|
253
|
+
writeError = result.error;
|
|
254
|
+
}
|
|
255
|
+
ownSession?.finalize();
|
|
256
|
+
let syncReport;
|
|
257
|
+
if (!writeError && plan.action === "updated" && existsSync(join(homeDir, ".trellis"))) {
|
|
258
|
+
syncReport = await collectSyncReport({ target: "skills", homeDir, dryRun: opts.dryRun });
|
|
259
|
+
}
|
|
260
|
+
return { plan, ...(writeError ? { writeError } : {}), ...(syncReport ? { sync: syncReport } : {}) };
|
|
261
|
+
}
|
|
262
|
+
export async function runSkillScope(name, raw, opts = {}) {
|
|
263
|
+
const homeDir = opts.homeDir ?? homedir();
|
|
264
|
+
let outcome;
|
|
265
|
+
try {
|
|
266
|
+
outcome = await applySkillScopeWithSync(collectSkillScopePlan(name, raw, homeDir), { homeDir, dryRun: opts.dryRun });
|
|
267
|
+
}
|
|
268
|
+
catch (err) {
|
|
269
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
270
|
+
return { exitCode: 1 };
|
|
271
|
+
}
|
|
272
|
+
const { plan, writeError, sync: syncReport } = outcome;
|
|
273
|
+
if (opts.json) {
|
|
274
|
+
const payload = writeError ? { ...plan, writeError } : plan;
|
|
275
|
+
console.log(JSON.stringify(syncReport ? { ...payload, sync: syncReport } : payload, null, 2));
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
console.log(`${opts.dryRun ? "[dry run] " : ""}skill scope ${plan.name}`);
|
|
279
|
+
console.log(` [${plan.action}] ${plan.detail}`);
|
|
280
|
+
if (writeError)
|
|
281
|
+
console.error(` write failed: ${writeError}`);
|
|
282
|
+
if (syncReport)
|
|
283
|
+
printSyncReport(syncReport, opts.dryRun ?? false);
|
|
284
|
+
}
|
|
285
|
+
const failed = plan.action === "invalid-input" || plan.action === "not-found" || Boolean(writeError);
|
|
286
|
+
const syncConflict = syncReport?.reports.some((r) => r.items.some((i) => i.action === "conflict")) ?? false;
|
|
287
|
+
return { exitCode: failed || syncConflict ? 1 : 0 };
|
|
288
|
+
}
|
package/dist/core/canonical.d.ts
CHANGED
|
@@ -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
|
}
|
|
@@ -79,11 +85,16 @@ export declare class LocalBackend implements GatewayBackend {
|
|
|
79
85
|
private readonly connected;
|
|
80
86
|
private readonly statuses;
|
|
81
87
|
private closed;
|
|
88
|
+
private readonly resources;
|
|
82
89
|
private constructor();
|
|
83
90
|
static connect(upstreams: readonly UpstreamSpec[], options: LocalBackendOptions): Promise<LocalBackend>;
|
|
84
91
|
private addUpstream;
|
|
85
92
|
listTools(): Promise<AggregatedTool[]>;
|
|
86
93
|
callTool(name: string, args?: Record<string, unknown>): Promise<unknown>;
|
|
94
|
+
listResources(): Promise<Resource[]>;
|
|
95
|
+
readResource(uri: string): Promise<ReadResourceResult>;
|
|
96
|
+
listSkills(): Promise<SkillEntry[]>;
|
|
97
|
+
getSkill(uri: string): Promise<SkillEntry | undefined>;
|
|
87
98
|
listStatus(): readonly UpstreamStatus[];
|
|
88
99
|
close(): Promise<void>;
|
|
89
100
|
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
|
}
|
|
@@ -107,6 +115,10 @@ export class LocalBackend {
|
|
|
107
115
|
async callTool(name, args) {
|
|
108
116
|
return this.registry.callTool(name, args);
|
|
109
117
|
}
|
|
118
|
+
listResources() { return this.resources.listResources(); }
|
|
119
|
+
readResource(uri) { return this.resources.readResource(uri); }
|
|
120
|
+
listSkills() { return this.resources.listSkills(); }
|
|
121
|
+
getSkill(uri) { return this.resources.getSkill(uri); }
|
|
110
122
|
listStatus() {
|
|
111
123
|
return [...this.statuses.values()];
|
|
112
124
|
}
|
|
@@ -189,9 +201,16 @@ function failureStatus(name, def, error) {
|
|
|
189
201
|
* the kind of invisible precedence that is impossible to debug.
|
|
190
202
|
*/
|
|
191
203
|
export async function withOAuthHeader(name, def, homeDir) {
|
|
204
|
+
if (!isOAuthAuth(def.auth) || oauthOwner(def.auth) !== "trellis")
|
|
205
|
+
return def;
|
|
192
206
|
const hasExplicitAuth = Object.keys(def.headers ?? {}).some((key) => key.toLowerCase() === "authorization");
|
|
193
207
|
if (hasExplicitAuth)
|
|
194
208
|
return def;
|
|
209
|
+
const stored = readToken(homeDir, name);
|
|
210
|
+
const resource = new URL(def.url);
|
|
211
|
+
resource.hash = "";
|
|
212
|
+
if (stored?.resourceUrl && stored.resourceUrl !== resource.toString())
|
|
213
|
+
throw new Error("OAuth authorization required: saved credential targets another MCP resource");
|
|
195
214
|
const token = await ensureFreshToken(homeDir, name);
|
|
196
215
|
if (!token)
|
|
197
216
|
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,7 +1,8 @@
|
|
|
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
|
+
import type { SkillEntry } from "./skillManifest.js";
|
|
5
6
|
export interface RuntimeContext {
|
|
6
7
|
agentId: AgentId;
|
|
7
8
|
homeDir: string;
|
|
@@ -14,6 +15,9 @@ export interface TrellisProvider {
|
|
|
14
15
|
readResource?(uri: URL, context: RuntimeContext): Promise<ReadResourceResult> | ReadResourceResult;
|
|
15
16
|
listPrompts?(context: RuntimeContext): Promise<Prompt[]> | Prompt[];
|
|
16
17
|
getPrompt?(name: string, args: Record<string, string> | undefined, context: RuntimeContext): Promise<GetPromptResult> | GetPromptResult;
|
|
18
|
+
/** Skills-over-MCP `skills/list` and `skills/get` (trellis-skills-over-mcp). */
|
|
19
|
+
listSkillEntries?(context: RuntimeContext): Promise<SkillEntry[]> | SkillEntry[];
|
|
20
|
+
getSkillEntry?(uri: string, context: RuntimeContext): Promise<SkillEntry | undefined> | SkillEntry | undefined;
|
|
17
21
|
close?(): Promise<void>;
|
|
18
22
|
}
|
|
19
23
|
export declare class BuiltinRegistry {
|
|
@@ -29,6 +33,8 @@ export declare class BuiltinRegistry {
|
|
|
29
33
|
callTool(name: string, args: unknown, context: RuntimeContext): Promise<CallToolResult>;
|
|
30
34
|
listResources(context: RuntimeContext): Promise<Resource[]>;
|
|
31
35
|
readResource(uri: URL, context: RuntimeContext): Promise<ReadResourceResult>;
|
|
36
|
+
listSkills(context: RuntimeContext): Promise<SkillEntry[]>;
|
|
37
|
+
getSkill(uri: string, context: RuntimeContext): Promise<SkillEntry | undefined>;
|
|
32
38
|
listPrompts(context: RuntimeContext): Promise<Prompt[]>;
|
|
33
39
|
getPrompt(name: string, args: Record<string, string> | undefined, context: RuntimeContext): Promise<GetPromptResult>;
|
|
34
40
|
close(): Promise<void>;
|
|
@@ -41,6 +47,10 @@ export declare class UpstreamProvider implements TrellisProvider {
|
|
|
41
47
|
constructor(backend: GatewayBackend);
|
|
42
48
|
listTools(): Promise<Tool[]>;
|
|
43
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>;
|
|
44
54
|
close(): Promise<void>;
|
|
45
55
|
}
|
|
46
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,10 +103,42 @@ 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);
|
|
108
112
|
}
|
|
113
|
+
async listSkills(context) {
|
|
114
|
+
const skills = [];
|
|
115
|
+
for (const provider of this.providers) {
|
|
116
|
+
if (!provider.listSkillEntries)
|
|
117
|
+
continue;
|
|
118
|
+
try {
|
|
119
|
+
skills.push(...(await provider.listSkillEntries(context)));
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
this.warn(providerError(provider.id, err));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return skills;
|
|
126
|
+
}
|
|
127
|
+
async getSkill(uri, context) {
|
|
128
|
+
for (const provider of this.providers) {
|
|
129
|
+
if (!provider.getSkillEntry)
|
|
130
|
+
continue;
|
|
131
|
+
try {
|
|
132
|
+
const entry = await provider.getSkillEntry(uri, context);
|
|
133
|
+
if (entry)
|
|
134
|
+
return entry;
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
this.warn(providerError(provider.id, err));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
109
142
|
async listPrompts(context) {
|
|
110
143
|
const prompts = [];
|
|
111
144
|
this.promptOwners.clear();
|
|
@@ -161,8 +194,16 @@ export class UpstreamProvider {
|
|
|
161
194
|
return (await this.backend.listTools());
|
|
162
195
|
}
|
|
163
196
|
async callTool(name, args) {
|
|
164
|
-
return (await this.backend.callTool(name, args));
|
|
197
|
+
return withoutUpstreamServerInfo((await this.backend.callTool(name, args)));
|
|
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());
|
|
165
204
|
}
|
|
205
|
+
async listSkillEntries() { return await this.backend.listSkills?.() ?? []; }
|
|
206
|
+
async getSkillEntry(uri) { return this.backend.getSkill?.(uri); }
|
|
166
207
|
async close() {
|
|
167
208
|
await this.backend.close();
|
|
168
209
|
}
|
|
@@ -173,13 +214,33 @@ export function createRuntimeServer(serverInfo, context, registry) {
|
|
|
173
214
|
tools: { listChanged: true },
|
|
174
215
|
resources: { listChanged: true },
|
|
175
216
|
prompts: { listChanged: true },
|
|
217
|
+
extensions: { "io.modelcontextprotocol/skills": {} },
|
|
176
218
|
},
|
|
177
219
|
});
|
|
178
|
-
server.setRequestHandler(
|
|
179
|
-
server.setRequestHandler(
|
|
180
|
-
server.setRequestHandler(
|
|
181
|
-
server.setRequestHandler(
|
|
182
|
-
|
|
183
|
-
|
|
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);
|
|
226
|
+
}
|
|
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
|
+
}
|
|
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
|
+
});
|
|
184
245
|
return server;
|
|
185
246
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
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
4
|
export declare class McpStatusProvider implements TrellisProvider {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CallToolResult, ReadResourceResult, Resource, Tool } from "@modelcontextprotocol/
|
|
1
|
+
import type { CallToolResult, ReadResourceResult, Resource, Tool } from "@modelcontextprotocol/server";
|
|
2
2
|
import type { Scope } from "../core/types.js";
|
|
3
3
|
import type { RuntimeContext, TrellisProvider } from "./mcpRuntime.js";
|
|
4
4
|
/**
|
|
@@ -27,6 +27,7 @@ export interface AuthorizationServerMetadata {
|
|
|
27
27
|
registrationEndpoint?: string;
|
|
28
28
|
scopesSupported?: string[];
|
|
29
29
|
codeChallengeMethodsSupported?: string[];
|
|
30
|
+
issuerParameterSupported?: boolean;
|
|
30
31
|
}
|
|
31
32
|
export type FetchLike = (url: string, init?: {
|
|
32
33
|
method?: string;
|
|
@@ -37,6 +37,8 @@ function toMetadata(raw) {
|
|
|
37
37
|
out.scopesSupported = raw.scopes_supported;
|
|
38
38
|
if (raw.code_challenge_methods_supported)
|
|
39
39
|
out.codeChallengeMethodsSupported = raw.code_challenge_methods_supported;
|
|
40
|
+
if (raw.authorization_response_iss_parameter_supported)
|
|
41
|
+
out.issuerParameterSupported = true;
|
|
40
42
|
return out;
|
|
41
43
|
}
|
|
42
44
|
async function tryJson(fetchImpl, url) {
|