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.
Files changed (45) hide show
  1. package/dist/adapters/mcpPlan.d.ts +1 -0
  2. package/dist/adapters/mcpPlan.js +14 -5
  3. package/dist/cli.js +30 -5
  4. package/dist/commands/mcp.d.ts +71 -0
  5. package/dist/commands/mcp.js +145 -4
  6. package/dist/commands/mcpAuth.d.ts +6 -0
  7. package/dist/commands/mcpAuth.js +38 -10
  8. package/dist/commands/mcpGateway.js +7 -7
  9. package/dist/commands/skill.d.ts +45 -1
  10. package/dist/commands/skill.js +83 -2
  11. package/dist/core/canonical.d.ts +1 -0
  12. package/dist/core/canonical.js +8 -3
  13. package/dist/core/types.d.ts +4 -1
  14. package/dist/core/types.js +3 -0
  15. package/dist/lib/agentBridgeProvider.d.ts +1 -1
  16. package/dist/lib/gatewayBackend.d.ts +11 -0
  17. package/dist/lib/gatewayBackend.js +23 -4
  18. package/dist/lib/mcpConnect.d.ts +6 -3
  19. package/dist/lib/mcpConnect.js +8 -6
  20. package/dist/lib/mcpRuntime.d.ts +12 -2
  21. package/dist/lib/mcpRuntime.js +70 -9
  22. package/dist/lib/mcpStatusProvider.d.ts +1 -1
  23. package/dist/lib/memoryProvider.d.ts +1 -1
  24. package/dist/lib/oauth/discovery.d.ts +1 -0
  25. package/dist/lib/oauth/discovery.js +2 -0
  26. package/dist/lib/oauth/flow.d.ts +4 -0
  27. package/dist/lib/oauth/flow.js +48 -11
  28. package/dist/lib/oauth/refresh.d.ts +1 -0
  29. package/dist/lib/oauth/refresh.js +3 -1
  30. package/dist/lib/oauth/store.d.ts +2 -0
  31. package/dist/lib/runtimeControlProvider.d.ts +1 -1
  32. package/dist/lib/scopeSelection.d.ts +36 -0
  33. package/dist/lib/scopeSelection.js +65 -0
  34. package/dist/lib/skillManifest.d.ts +66 -0
  35. package/dist/lib/skillManifest.js +203 -0
  36. package/dist/lib/skillProvider.d.ts +21 -1
  37. package/dist/lib/skillProvider.js +61 -1
  38. package/dist/lib/taskProvider.d.ts +1 -1
  39. package/dist/lib/upstreamResources.d.ts +23 -0
  40. package/dist/lib/upstreamResources.js +192 -0
  41. package/dist/pi-bridge/bundle.js +37906 -19932
  42. package/docs/architecture.md +14 -13
  43. package/docs/mcp-authorization-ownership.md +59 -0
  44. package/docs/skills-over-mcp.md +91 -0
  45. package/package.json +6 -3
@@ -5,6 +5,7 @@ import { parse as parseYaml } from "yaml";
5
5
  import { isInScope } from "../core/adapter.js";
6
6
  import { loadCanonicalSource } from "../core/canonical.js";
7
7
  import { resolveScope } from "../core/types.js";
8
+ import { buildSkillEntry, parseSkillUri, readSkillFileBytes, toResourceContent } from "./skillManifest.js";
8
9
  const MAX_FILE_BYTES = 256 * 1024;
9
10
  const SKILL_RESOURCE_PREFIX = "trellis://skills/";
10
11
  const SEARCH_TOOL = {
@@ -99,7 +100,47 @@ function skillMetadata(skill, canonical, agentId, content) {
99
100
  };
100
101
  }
101
102
  export class SkillProvider {
103
+ onDiagnostic;
102
104
  id = "skills";
105
+ /** Each omitted skill is reported once per process, not on every request. */
106
+ reported = new Set();
107
+ constructor(onDiagnostic = (message) => console.error(message)) {
108
+ this.onDiagnostic = onDiagnostic;
109
+ }
110
+ /**
111
+ * The skills this agent may be served under `skill://`, each with a manifest
112
+ * computed from the bytes it would read (trellis-skills-over-mcp D2/D3). A
113
+ * skill that cannot be served validly is omitted and reported, never served
114
+ * under a URI the specification calls invalid.
115
+ */
116
+ catalog(canonical, agentId) {
117
+ const out = [];
118
+ for (const skill of canonical.skills) {
119
+ if (!hasSkillDocument(skill) || !isInScope(agentId, skill.scope, canonical.managedAgents))
120
+ continue;
121
+ const built = buildSkillEntry(skill.name, skill.dir);
122
+ if (built.ok) {
123
+ out.push({ skill, entry: built.entry });
124
+ }
125
+ else if (!this.reported.has(built.reason)) {
126
+ this.reported.add(built.reason);
127
+ this.onDiagnostic(`trellis-mcp-runtime: skill not served under skill://: ${built.reason}`);
128
+ }
129
+ }
130
+ return out;
131
+ }
132
+ /** `skills/list` — one entry per servable, in-scope skill. */
133
+ listSkillEntries(context) {
134
+ return this.catalog(loadCanonicalSource(context.homeDir), context.agentId).map(({ entry }) => entry);
135
+ }
136
+ /** `skills/get` — lookup by URI, independent of listing. Out-of-scope and
137
+ * unknown URIs are indistinguishable by design. */
138
+ getSkillEntry(uri, context) {
139
+ const parsed = parseSkillUri(uri);
140
+ if (!parsed || parsed.path !== "SKILL.md")
141
+ return undefined;
142
+ return this.listSkillEntries(context).find((entry) => entry.uri === uri);
143
+ }
103
144
  listTools() {
104
145
  return [SEARCH_TOOL, READ_TOOL, READ_FILE_TOOL];
105
146
  }
@@ -116,7 +157,7 @@ export class SkillProvider {
116
157
  }
117
158
  listResources(context) {
118
159
  const canonical = loadCanonicalSource(context.homeDir);
119
- return canonical.skills
160
+ const legacy = canonical.skills
120
161
  .filter((skill) => hasSkillDocument(skill) && isInScope(context.agentId, skill.scope, canonical.managedAgents))
121
162
  .map((skill) => ({
122
163
  uri: skillUri(skill.name),
@@ -124,8 +165,15 @@ export class SkillProvider {
124
165
  description: `Instructions for the ${skill.name} skill`,
125
166
  mimeType: "text/markdown",
126
167
  }));
168
+ // The convention's view of the same data, additive to the above
169
+ // (design.md D1): every manifest file, so a host that lists resources
170
+ // sees exactly what skills/list advertises.
171
+ const conventional = this.catalog(canonical, context.agentId).flatMap(({ entry }) => entry.resources.map((file) => ({ uri: file.uri, name: file.uri.slice("skill://".length), mimeType: file.uri.endsWith(".md") ? "text/markdown" : "text/plain", size: file.size })));
172
+ return [...legacy, ...conventional];
127
173
  }
128
174
  readResource(uri, context) {
175
+ if (uri.protocol === "skill:")
176
+ return this.readConventionalResource(uri.toString(), context);
129
177
  if (uri.protocol !== "trellis:" || uri.hostname !== "skills") {
130
178
  throw new Error(`unsupported skill resource URI "${uri.toString()}"`);
131
179
  }
@@ -140,6 +188,18 @@ export class SkillProvider {
140
188
  const content = this.readBoundedFile(resolve(skill.dir, "SKILL.md"), skill.dir);
141
189
  return { contents: [{ uri: uri.toString(), mimeType: "text/markdown", text: content }] };
142
190
  }
191
+ /** Serves only what the manifest advertises, from the same reader that
192
+ * computed its digests — the two cannot disagree. */
193
+ readConventionalResource(uri, context) {
194
+ const parsed = parseSkillUri(uri);
195
+ const canonical = loadCanonicalSource(context.homeDir);
196
+ const skill = parsed ? skillFor(canonical, context.agentId, parsed.name) : undefined;
197
+ const served = skill && parsed ? this.catalog(canonical, context.agentId).find(({ entry }) => entry.uri === `skill://${parsed.name}/SKILL.md`) : undefined;
198
+ if (!parsed || !skill || !served || !served.entry.resources.some((file) => file.uri === uri)) {
199
+ throw new Error(`skill resource "${uri}" is not available to ${context.agentId}`);
200
+ }
201
+ return { contents: [toResourceContent(uri, parsed.path, readSkillFileBytes(skill.dir, parsed.path))] };
202
+ }
143
203
  search(canonical, agentId, input) {
144
204
  const query = typeof input.query === "string" ? input.query.trim().toLowerCase() : "";
145
205
  const limit = typeof input.limit === "number" ? Math.min(50, Math.max(1, Math.floor(input.limit))) : 20;
@@ -1,4 +1,4 @@
1
- import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
1
+ import type { CallToolResult, Tool } from "@modelcontextprotocol/server";
2
2
  import type { RuntimeContext, TrellisProvider } from "./mcpRuntime.js";
3
3
  export declare class TaskProvider implements TrellisProvider {
4
4
  readonly id = "tasks";
@@ -0,0 +1,23 @@
1
+ import type { Client, Resource, ReadResourceResult } from "@modelcontextprotocol/client";
2
+ import { type SkillEntry } from "./skillManifest.js";
3
+ /** The outer response belongs to the gateway; upstream identity is provenance. */
4
+ export declare function withoutUpstreamServerInfo<T extends {
5
+ _meta?: Record<string, unknown>;
6
+ }>(result: T): T;
7
+ /** Retain path segments so relative supporting-file references remain valid. */
8
+ export declare function upstreamUri(source: string, uri: string): string;
9
+ export declare class UpstreamResources {
10
+ private readonly timeoutMs;
11
+ private readonly warn;
12
+ private readonly clients;
13
+ private readonly files;
14
+ constructor(timeoutMs: number, warn: (message: string) => void);
15
+ add(source: string, client: Client): void;
16
+ private skillsEnabled;
17
+ private isolated;
18
+ listResources(): Promise<Resource[]>;
19
+ private registerSkill;
20
+ listSkills(): Promise<SkillEntry[]>;
21
+ getSkill(uri: string): Promise<SkillEntry | undefined>;
22
+ readResource(uri: string): Promise<ReadResourceResult>;
23
+ }
@@ -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
+ }