agent-trellis 0.11.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 +1 -1
- 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.js +7 -7
- 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 +6 -2
- package/dist/lib/mcpRuntime.js +39 -29
- 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/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/mcp-authorization-ownership.md +59 -0
- package/docs/skills-over-mcp.md +43 -31
- package/package.json +5 -2
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { z } from "zod/v4";
|
|
2
|
+
import { isDeepStrictEqual } from "node:util";
|
|
3
|
+
import { parse as parseYaml } from "yaml";
|
|
4
|
+
import { digestOf, MAX_SKILL_BYTES, MAX_SKILL_FILES } from "./skillManifest.js";
|
|
5
|
+
import { withTimeout } from "./mcpConnect.js";
|
|
6
|
+
const EXTENSION = "io.modelcontextprotocol/skills";
|
|
7
|
+
const listSchema = z.looseObject({ skills: z.array(z.unknown()), nextCursor: z.string().optional() });
|
|
8
|
+
const getSchema = z.looseObject({ skill: z.unknown() });
|
|
9
|
+
const PREFIX = "trellis-upstream:";
|
|
10
|
+
/** The outer response belongs to the gateway; upstream identity is provenance. */
|
|
11
|
+
export function withoutUpstreamServerInfo(result) {
|
|
12
|
+
if (!result._meta || !("io.modelcontextprotocol/serverInfo" in result._meta))
|
|
13
|
+
return result;
|
|
14
|
+
const { "io.modelcontextprotocol/serverInfo": _server, ...meta } = result._meta;
|
|
15
|
+
return { ...result, _meta: meta };
|
|
16
|
+
}
|
|
17
|
+
/** Retain path segments so relative supporting-file references remain valid. */
|
|
18
|
+
export function upstreamUri(source, uri) {
|
|
19
|
+
const original = new URL(uri);
|
|
20
|
+
if (original.username || original.password || !original.pathname.startsWith("/"))
|
|
21
|
+
throw new Error("resource URI must be hierarchical and contain no credentials");
|
|
22
|
+
const label = `s${Buffer.from(source).toString("hex")}`;
|
|
23
|
+
const protocol = Buffer.from(original.protocol).toString("base64url");
|
|
24
|
+
return `${PREFIX}//${label}/${protocol}/${encodeURIComponent(original.host) || "~"}${original.pathname}${original.search}${original.hash}`;
|
|
25
|
+
}
|
|
26
|
+
function originalUri(uri) {
|
|
27
|
+
try {
|
|
28
|
+
const exposed = new URL(uri);
|
|
29
|
+
if (exposed.protocol !== PREFIX || !/^s(?:[0-9a-f]{2})+$/.test(exposed.hostname))
|
|
30
|
+
return undefined;
|
|
31
|
+
const match = /^\/([^/]+)\/([^/]+)(\/.*)$/.exec(exposed.pathname);
|
|
32
|
+
if (!match)
|
|
33
|
+
return undefined;
|
|
34
|
+
const protocol = Buffer.from(match[1], "base64url").toString();
|
|
35
|
+
if (!/^[a-z][a-z0-9+.-]*:$/.test(protocol))
|
|
36
|
+
return undefined;
|
|
37
|
+
const source = Buffer.from(exposed.hostname.slice(1), "hex").toString();
|
|
38
|
+
const original = `${protocol}//${match[2] === "~" ? "" : decodeURIComponent(match[2])}${match[3]}${exposed.search}${exposed.hash}`;
|
|
39
|
+
if (upstreamUri(source, original) !== uri)
|
|
40
|
+
return undefined;
|
|
41
|
+
return { source, original };
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export class UpstreamResources {
|
|
48
|
+
timeoutMs;
|
|
49
|
+
warn;
|
|
50
|
+
clients = new Map();
|
|
51
|
+
files = new Map();
|
|
52
|
+
constructor(timeoutMs, warn) {
|
|
53
|
+
this.timeoutMs = timeoutMs;
|
|
54
|
+
this.warn = warn;
|
|
55
|
+
}
|
|
56
|
+
add(source, client) { this.clients.set(source, client); }
|
|
57
|
+
skillsEnabled(client) {
|
|
58
|
+
return Boolean(client.getServerCapabilities()?.resources && client.getDiscoverResult()?.capabilities.extensions?.[EXTENSION]);
|
|
59
|
+
}
|
|
60
|
+
async isolated(source, work, fallback) {
|
|
61
|
+
try {
|
|
62
|
+
return await withTimeout(work(), this.timeoutMs, "upstream resource request timed out");
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
this.warn(`trellis-mcp-runtime: upstream "${source}" resources: ${err instanceof Error ? err.message : String(err)}`);
|
|
66
|
+
return fallback;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async listResources() {
|
|
70
|
+
const lists = await Promise.all([...this.clients].map(([source, client]) => this.isolated(source, async () => {
|
|
71
|
+
if (!client.getServerCapabilities()?.resources)
|
|
72
|
+
return [];
|
|
73
|
+
const { resources } = await client.listResources();
|
|
74
|
+
if (resources.length > 8192)
|
|
75
|
+
throw new Error("upstream resource catalog exceeds 8192 entries");
|
|
76
|
+
return resources.map((resource) => {
|
|
77
|
+
const uri = upstreamUri(source, resource.uri);
|
|
78
|
+
if (!this.files.get(uri)?.manifest)
|
|
79
|
+
this.files.set(uri, { source, original: resource.uri });
|
|
80
|
+
return { ...resource, uri, _meta: { ...resource._meta, "io.trellis/origin": { server: source, uri: resource.uri } } };
|
|
81
|
+
});
|
|
82
|
+
}, [])));
|
|
83
|
+
return lists.flat();
|
|
84
|
+
}
|
|
85
|
+
registerSkill(source, value) {
|
|
86
|
+
if (!value || typeof value !== "object")
|
|
87
|
+
throw new Error("invalid upstream skill entry");
|
|
88
|
+
const entry = value;
|
|
89
|
+
if (typeof entry.uri !== "string" || !entry.frontmatter || typeof entry.frontmatter.name !== "string" || typeof entry.frontmatter.description !== "string" || !entry.frontmatter.description.trim())
|
|
90
|
+
throw new Error("invalid upstream skill metadata");
|
|
91
|
+
if (!Array.isArray(entry.resources) || entry.resources.length === 0 || entry.resources.length > MAX_SKILL_FILES)
|
|
92
|
+
throw new Error("upstream skill needs a bounded, complete manifest");
|
|
93
|
+
const root = new URL(".", entry.uri).toString();
|
|
94
|
+
if (!new URL(entry.uri).pathname.endsWith("/SKILL.md") || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(entry.frontmatter.name) || entry.frontmatter.name.length > 64)
|
|
95
|
+
throw new Error("invalid upstream skill name or entry URI");
|
|
96
|
+
const directory = new URL(root);
|
|
97
|
+
const parent = directory.pathname.split("/").filter(Boolean).at(-1) ?? directory.hostname;
|
|
98
|
+
if (decodeURIComponent(parent) !== entry.frontmatter.name)
|
|
99
|
+
throw new Error("upstream skill name does not match its directory");
|
|
100
|
+
const seen = new Set();
|
|
101
|
+
let total = 0;
|
|
102
|
+
for (const file of entry.resources) {
|
|
103
|
+
if (!file || typeof file.uri !== "string" || !file.uri.startsWith(root) || !/^sha256:[a-f0-9]{64}$/.test(file.digest) || !Number.isSafeInteger(file.size) || file.size < 0 || seen.has(file.uri))
|
|
104
|
+
throw new Error("invalid upstream skill manifest");
|
|
105
|
+
const parsed = new URL(file.uri);
|
|
106
|
+
if (!parsed.toString().startsWith(root))
|
|
107
|
+
throw new Error("skill file escaped its source directory");
|
|
108
|
+
upstreamUri(source, file.uri);
|
|
109
|
+
seen.add(file.uri);
|
|
110
|
+
total += file.size;
|
|
111
|
+
}
|
|
112
|
+
if (total > MAX_SKILL_BYTES || !seen.has(entry.uri))
|
|
113
|
+
throw new Error("upstream skill manifest exceeds limits or omits SKILL.md");
|
|
114
|
+
const uri = upstreamUri(source, entry.uri);
|
|
115
|
+
for (const [key, route] of this.files)
|
|
116
|
+
if (route.skill === uri)
|
|
117
|
+
this.files.delete(key);
|
|
118
|
+
const resources = entry.resources.map((file) => {
|
|
119
|
+
const exposed = upstreamUri(source, file.uri);
|
|
120
|
+
this.files.set(exposed, { source, original: file.uri, manifest: file, skill: uri, ...(file.uri === entry.uri ? { frontmatter: entry.frontmatter } : {}) });
|
|
121
|
+
return { ...file, uri: exposed };
|
|
122
|
+
});
|
|
123
|
+
return { ...entry, uri, resources, _meta: { "io.trellis/origin": { server: source, uri: entry.uri } } };
|
|
124
|
+
}
|
|
125
|
+
async listSkills() {
|
|
126
|
+
const lists = await Promise.all([...this.clients].map(([source, client]) => this.isolated(source, async () => {
|
|
127
|
+
if (!this.skillsEnabled(client))
|
|
128
|
+
return [];
|
|
129
|
+
const entries = [];
|
|
130
|
+
let cursor;
|
|
131
|
+
const cursors = new Set();
|
|
132
|
+
for (let page = 0; page < 64; page++) {
|
|
133
|
+
const result = await client.request({ method: "skills/list", params: cursor ? { cursor } : {} }, listSchema, { timeout: this.timeoutMs });
|
|
134
|
+
for (const entry of result.skills) {
|
|
135
|
+
if (entries.length >= 2048)
|
|
136
|
+
throw new Error("upstream skill catalog exceeds 2048 entries");
|
|
137
|
+
try {
|
|
138
|
+
entries.push(this.registerSkill(source, entry));
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
this.warn(`trellis-mcp-runtime: omitted skill from "${source}": ${err instanceof Error ? err.message : String(err)}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (!result.nextCursor)
|
|
145
|
+
return entries;
|
|
146
|
+
if (cursors.has(result.nextCursor))
|
|
147
|
+
throw new Error("repeated upstream skill cursor");
|
|
148
|
+
cursors.add(result.nextCursor);
|
|
149
|
+
cursor = result.nextCursor;
|
|
150
|
+
}
|
|
151
|
+
throw new Error("upstream skill pagination exceeded 64 pages");
|
|
152
|
+
}, [])));
|
|
153
|
+
return lists.flat();
|
|
154
|
+
}
|
|
155
|
+
async getSkill(uri) {
|
|
156
|
+
const decoded = originalUri(uri);
|
|
157
|
+
if (!decoded)
|
|
158
|
+
return undefined;
|
|
159
|
+
const client = this.clients.get(decoded.source);
|
|
160
|
+
if (!client || !this.skillsEnabled(client))
|
|
161
|
+
return undefined;
|
|
162
|
+
const result = await client.request({ method: "skills/get", params: { uri: decoded.original } }, getSchema, { timeout: this.timeoutMs });
|
|
163
|
+
if (result.skill?.uri !== decoded.original)
|
|
164
|
+
throw new Error("upstream returned another skill identity");
|
|
165
|
+
const entry = this.registerSkill(decoded.source, result.skill);
|
|
166
|
+
if (entry.uri !== uri)
|
|
167
|
+
throw new Error("upstream returned another skill identity");
|
|
168
|
+
return entry;
|
|
169
|
+
}
|
|
170
|
+
async readResource(uri) {
|
|
171
|
+
const route = this.files.get(uri);
|
|
172
|
+
const client = route && this.clients.get(route.source);
|
|
173
|
+
if (!route || !client)
|
|
174
|
+
throw new Error("unknown upstream resource");
|
|
175
|
+
const result = await client.readResource({ uri: route.original }, { timeout: this.timeoutMs });
|
|
176
|
+
if (result.contents.length !== 1 || result.contents[0].uri !== route.original)
|
|
177
|
+
throw new Error("upstream resource identity mismatch");
|
|
178
|
+
const content = result.contents[0];
|
|
179
|
+
if (route.manifest) {
|
|
180
|
+
const bytes = "blob" in content ? Buffer.from(content.blob, "base64") : Buffer.from(content.text, "utf8");
|
|
181
|
+
if (bytes.length !== route.manifest.size || digestOf(bytes) !== route.manifest.digest)
|
|
182
|
+
throw new Error("upstream skill file differs from its manifest; refresh the skill entry");
|
|
183
|
+
if (route.frontmatter) {
|
|
184
|
+
const text = new TextDecoder("utf8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
185
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text);
|
|
186
|
+
if (!match || !isDeepStrictEqual(parseYaml(match[1]), route.frontmatter))
|
|
187
|
+
throw new Error("upstream skill frontmatter differs from its entry");
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return { ...withoutUpstreamServerInfo(result), ttlMs: 0, cacheScope: "private", contents: [{ ...content, uri }] };
|
|
191
|
+
}
|
|
192
|
+
}
|