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/lib/oauth/flow.d.ts
CHANGED
|
@@ -31,6 +31,9 @@ export interface AuthorizeOptions {
|
|
|
31
31
|
* is actually bound, so a fixed one is never required. */
|
|
32
32
|
callbackPort?: number;
|
|
33
33
|
timeoutMs?: number;
|
|
34
|
+
signal?: AbortSignal;
|
|
35
|
+
resourceUrl?: string;
|
|
36
|
+
onProgress?: (phase: "registering" | "waiting" | "exchanging") => void;
|
|
34
37
|
}
|
|
35
38
|
/**
|
|
36
39
|
* RFC 7591. Returns `undefined` when the AS advertises no registration
|
|
@@ -58,3 +61,4 @@ export declare function registerClient(metadata: AuthorizationServerMetadata, re
|
|
|
58
61
|
* caller's decision, so this stays testable without a home directory.
|
|
59
62
|
*/
|
|
60
63
|
export declare function authorize(serverName: string, metadata: AuthorizationServerMetadata, opts?: AuthorizeOptions): Promise<StoredToken>;
|
|
64
|
+
export declare function abortable<T>(value: T | Promise<T>, signal?: AbortSignal): Promise<T>;
|
package/dist/lib/oauth/flow.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { createServer } from "node:http";
|
|
17
17
|
import { randomBytes } from "node:crypto";
|
|
18
|
+
import { spawn } from "node:child_process";
|
|
18
19
|
import { createPkcePair } from "./pkce.js";
|
|
19
20
|
export class AuthorizationError extends Error {
|
|
20
21
|
}
|
|
@@ -89,6 +90,11 @@ function listenForCallback(port) {
|
|
|
89
90
|
});
|
|
90
91
|
const server = createServer((req, res) => {
|
|
91
92
|
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
93
|
+
if (req.method !== "GET" || url.pathname !== "/callback") {
|
|
94
|
+
res.writeHead(404);
|
|
95
|
+
res.end();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
92
98
|
const result = {};
|
|
93
99
|
const code = url.searchParams.get("code");
|
|
94
100
|
const state = url.searchParams.get("state");
|
|
@@ -97,6 +103,9 @@ function listenForCallback(port) {
|
|
|
97
103
|
result.code = code;
|
|
98
104
|
if (state)
|
|
99
105
|
result.state = state;
|
|
106
|
+
const issuer = url.searchParams.get("iss");
|
|
107
|
+
if (issuer)
|
|
108
|
+
result.issuer = issuer;
|
|
100
109
|
if (error) {
|
|
101
110
|
result.error = error;
|
|
102
111
|
const description = url.searchParams.get("error_description");
|
|
@@ -106,7 +115,7 @@ function listenForCallback(port) {
|
|
|
106
115
|
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
107
116
|
res.end(result.error
|
|
108
117
|
? `<html><body><h1>Authorization failed</h1><p>${escapeHtml(result.errorDescription ?? result.error)}</p></body></html>`
|
|
109
|
-
: "<html><body><h1>
|
|
118
|
+
: "<html><body><h1>Authorization response received</h1><p>You can close this tab and return to Trellis.</p></body></html>");
|
|
110
119
|
settle(result);
|
|
111
120
|
});
|
|
112
121
|
server.on("error", reject);
|
|
@@ -124,7 +133,8 @@ function escapeHtml(value) {
|
|
|
124
133
|
* caller's decision, so this stays testable without a home directory.
|
|
125
134
|
*/
|
|
126
135
|
export async function authorize(serverName, metadata, opts = {}) {
|
|
127
|
-
|
|
136
|
+
opts.signal?.throwIfAborted();
|
|
137
|
+
const fetchImpl = opts.fetchImpl ?? ((url, init) => fetch(url, { ...init, signal: opts.signal }));
|
|
128
138
|
const now = opts.now ?? Date.now;
|
|
129
139
|
const timeoutMs = opts.timeoutMs ?? 300_000;
|
|
130
140
|
const { server, port, received } = await listenForCallback(opts.callbackPort ?? 0);
|
|
@@ -133,13 +143,14 @@ export async function authorize(serverName, metadata, opts = {}) {
|
|
|
133
143
|
let clientId = opts.clientId;
|
|
134
144
|
let clientSecret = opts.clientSecret;
|
|
135
145
|
if (!clientId) {
|
|
146
|
+
opts.onProgress?.("registering");
|
|
136
147
|
// The AS cannot register us AND canonical carries no client_id — the
|
|
137
148
|
// one dead end a person has to resolve by hand, so it is reported as
|
|
138
149
|
// such rather than as a generic registration failure
|
|
139
150
|
// (trellis-mcp-oauth-static-client design.md D3, task 2.3).
|
|
140
151
|
let registered;
|
|
141
152
|
try {
|
|
142
|
-
registered = await registerClient(metadata, redirectUri, { fetchImpl, scope: opts.scope });
|
|
153
|
+
registered = await abortable(registerClient(metadata, redirectUri, { fetchImpl, scope: opts.scope }), opts.signal);
|
|
143
154
|
}
|
|
144
155
|
catch (err) {
|
|
145
156
|
if (err instanceof AuthorizationError) {
|
|
@@ -164,8 +175,12 @@ export async function authorize(serverName, metadata, opts = {}) {
|
|
|
164
175
|
authUrl.searchParams.set("state", state);
|
|
165
176
|
if (opts.scope)
|
|
166
177
|
authUrl.searchParams.set("scope", opts.scope);
|
|
167
|
-
|
|
168
|
-
|
|
178
|
+
if (opts.resourceUrl)
|
|
179
|
+
authUrl.searchParams.set("resource", opts.resourceUrl);
|
|
180
|
+
opts.signal?.throwIfAborted();
|
|
181
|
+
opts.onProgress?.("waiting");
|
|
182
|
+
await abortable((opts.openBrowser ?? defaultOpenBrowser)(authUrl.toString()), opts.signal);
|
|
183
|
+
const callback = await withTimeout(received, timeoutMs, `timed out waiting for the authorization callback for "${serverName}"`, opts.signal);
|
|
169
184
|
if (callback.error) {
|
|
170
185
|
throw new AuthorizationError(`authorization for "${serverName}" was refused: ${callback.errorDescription ?? callback.error}`);
|
|
171
186
|
}
|
|
@@ -177,6 +192,11 @@ export async function authorize(serverName, metadata, opts = {}) {
|
|
|
177
192
|
if (callback.state !== state) {
|
|
178
193
|
throw new AuthorizationError(`authorization callback for "${serverName}" carried an unexpected state parameter`);
|
|
179
194
|
}
|
|
195
|
+
if ((callback.issuer && callback.issuer !== metadata.issuer) || (metadata.issuerParameterSupported && !callback.issuer)) {
|
|
196
|
+
throw new AuthorizationError("authorization callback issuer does not match the discovered authorization server");
|
|
197
|
+
}
|
|
198
|
+
opts.signal?.throwIfAborted();
|
|
199
|
+
opts.onProgress?.("exchanging");
|
|
180
200
|
const body = new URLSearchParams({
|
|
181
201
|
grant_type: "authorization_code",
|
|
182
202
|
code: callback.code,
|
|
@@ -186,11 +206,13 @@ export async function authorize(serverName, metadata, opts = {}) {
|
|
|
186
206
|
});
|
|
187
207
|
if (clientSecret)
|
|
188
208
|
body.set("client_secret", clientSecret);
|
|
189
|
-
|
|
209
|
+
if (opts.resourceUrl)
|
|
210
|
+
body.set("resource", opts.resourceUrl);
|
|
211
|
+
const response = await abortable(fetchImpl(metadata.tokenEndpoint, {
|
|
190
212
|
method: "POST",
|
|
191
213
|
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
192
214
|
body: body.toString(),
|
|
193
|
-
});
|
|
215
|
+
}), opts.signal);
|
|
194
216
|
if (!response.ok) {
|
|
195
217
|
throw new AuthorizationError(`token exchange for "${serverName}" failed with HTTP ${response.status}`);
|
|
196
218
|
}
|
|
@@ -202,6 +224,8 @@ export async function authorize(serverName, metadata, opts = {}) {
|
|
|
202
224
|
accessToken: payload.access_token,
|
|
203
225
|
tokenEndpoint: metadata.tokenEndpoint,
|
|
204
226
|
clientId,
|
|
227
|
+
issuer: metadata.issuer,
|
|
228
|
+
...(opts.resourceUrl ? { resourceUrl: opts.resourceUrl } : {}),
|
|
205
229
|
};
|
|
206
230
|
if (payload.refresh_token)
|
|
207
231
|
token.refreshToken = payload.refresh_token;
|
|
@@ -214,25 +238,38 @@ export async function authorize(serverName, metadata, opts = {}) {
|
|
|
214
238
|
return token;
|
|
215
239
|
}
|
|
216
240
|
finally {
|
|
241
|
+
server.closeAllConnections();
|
|
217
242
|
server.close();
|
|
218
243
|
}
|
|
219
244
|
}
|
|
220
|
-
function withTimeout(promise, timeoutMs, message) {
|
|
245
|
+
function withTimeout(promise, timeoutMs, message, signal) {
|
|
221
246
|
return new Promise((resolve, reject) => {
|
|
222
|
-
const
|
|
247
|
+
const abort = () => { clearTimeout(timer); signal?.removeEventListener("abort", abort); reject(signal?.reason ?? new Error("authorization cancelled")); };
|
|
248
|
+
const timer = setTimeout(() => { signal?.removeEventListener("abort", abort); reject(new AuthorizationError(message)); }, timeoutMs);
|
|
249
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
250
|
+
if (signal?.aborted)
|
|
251
|
+
abort();
|
|
223
252
|
promise.then((value) => {
|
|
224
253
|
clearTimeout(timer);
|
|
254
|
+
signal?.removeEventListener("abort", abort);
|
|
225
255
|
resolve(value);
|
|
226
256
|
}, (err) => {
|
|
227
257
|
clearTimeout(timer);
|
|
258
|
+
signal?.removeEventListener("abort", abort);
|
|
228
259
|
reject(err);
|
|
229
260
|
});
|
|
230
261
|
});
|
|
231
262
|
}
|
|
263
|
+
export function abortable(value, signal) {
|
|
264
|
+
return withTimeout(Promise.resolve(value), 300_000, "authorization timed out", signal);
|
|
265
|
+
}
|
|
232
266
|
async function defaultOpenBrowser(url) {
|
|
233
|
-
const { spawn } = await import("node:child_process");
|
|
234
267
|
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
235
268
|
// Detached and fully ignored: a browser that outlives this command is
|
|
236
269
|
// correct, and its stdio must not be inherited onto ours.
|
|
237
|
-
|
|
270
|
+
await new Promise((resolve, reject) => {
|
|
271
|
+
const child = spawn(command, [url], { detached: true, stdio: "ignore" });
|
|
272
|
+
child.once("error", reject);
|
|
273
|
+
child.once("spawn", () => { child.unref(); resolve(); });
|
|
274
|
+
});
|
|
238
275
|
}
|
|
@@ -12,6 +12,7 @@ import { type StoredToken } from "./store.js";
|
|
|
12
12
|
import type { FetchLike } from "./discovery.js";
|
|
13
13
|
export interface RefreshOptions {
|
|
14
14
|
fetchImpl?: FetchLike;
|
|
15
|
+
timeoutMs?: number;
|
|
15
16
|
/** Test seam; also lets a caller widen the early-refresh skew. */
|
|
16
17
|
now?: () => number;
|
|
17
18
|
skewMs?: number;
|
|
@@ -49,7 +49,7 @@ export async function ensureFreshToken(homeDir, serverName, opts = {}) {
|
|
|
49
49
|
}, { timeoutMs: opts.lockTimeoutMs, now });
|
|
50
50
|
}
|
|
51
51
|
export async function performRefresh(serverName, token, opts = {}) {
|
|
52
|
-
const fetchImpl = opts.fetchImpl ??
|
|
52
|
+
const fetchImpl = opts.fetchImpl ?? ((url, init) => fetch(url, { ...init, signal: AbortSignal.timeout(opts.timeoutMs ?? 10_000) }));
|
|
53
53
|
const now = opts.now ?? Date.now;
|
|
54
54
|
if (!token.tokenEndpoint) {
|
|
55
55
|
throw new RefreshError(`OAuth token for "${serverName}" has no recorded token endpoint — run \`trellis mcp auth ${serverName}\``);
|
|
@@ -59,6 +59,8 @@ export async function performRefresh(serverName, token, opts = {}) {
|
|
|
59
59
|
body.set("client_id", token.clientId);
|
|
60
60
|
if (token.clientSecret)
|
|
61
61
|
body.set("client_secret", token.clientSecret);
|
|
62
|
+
if (token.resourceUrl)
|
|
63
|
+
body.set("resource", token.resourceUrl);
|
|
62
64
|
const response = await fetchImpl(token.tokenEndpoint, {
|
|
63
65
|
method: "POST",
|
|
64
66
|
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
@@ -29,6 +29,8 @@ export interface StoredToken {
|
|
|
29
29
|
* metadata — the gateway's refresh path should be one request. */
|
|
30
30
|
tokenEndpoint?: string;
|
|
31
31
|
scope?: string;
|
|
32
|
+
resourceUrl?: string;
|
|
33
|
+
issuer?: string;
|
|
32
34
|
}
|
|
33
35
|
export declare function oauthDir(homeDir: string): string;
|
|
34
36
|
/**
|
|
@@ -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 { GatewayBackend } from "./gatewayBackend.js";
|
|
3
3
|
import type { RuntimeContext, TrellisProvider } from "./mcpRuntime.js";
|
|
4
4
|
export declare class RuntimeControlProvider implements TrellisProvider {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared agent-scope selection for `skill scope` / `mcp scope`
|
|
3
|
+
* (trellis-scope-editing-and-auth-status design.md D1–D3). Both commands
|
|
4
|
+
* take exactly one of `--agents a,b`, `--all`, `--none`; keeping the parsing,
|
|
5
|
+
* validation and normalization here means a skill and an MCP server can never
|
|
6
|
+
* disagree about what a given selection means.
|
|
7
|
+
*/
|
|
8
|
+
import type { AgentId } from "../core/types.js";
|
|
9
|
+
/** Raw, unvalidated flag values — the parser stays a dumb reader and every
|
|
10
|
+
* rule below lives in `resolveScopeSelection`. */
|
|
11
|
+
export interface ScopeSelectorRaw {
|
|
12
|
+
/** Comma-separated agent ids. */
|
|
13
|
+
agents?: string;
|
|
14
|
+
all?: boolean;
|
|
15
|
+
none?: boolean;
|
|
16
|
+
}
|
|
17
|
+
export type ScopeSelection = {
|
|
18
|
+
ok: true;
|
|
19
|
+
/** What to record: `undefined` clears the explicit scope. */
|
|
20
|
+
scope: AgentId[] | undefined;
|
|
21
|
+
mode: "all" | "none" | "explicit";
|
|
22
|
+
/** True when the user listed every managed agent and the explicit scope
|
|
23
|
+
* was dropped instead of recorded (design.md D3). */
|
|
24
|
+
normalizedFromFull: boolean;
|
|
25
|
+
} | {
|
|
26
|
+
ok: false;
|
|
27
|
+
detail: string;
|
|
28
|
+
};
|
|
29
|
+
export declare const SCOPE_SELECTOR_USAGE = "exactly one of --agents <ids>, --all, --none is required";
|
|
30
|
+
/** `argv.indexOf`-style reader, same idiom as every other command's flags. */
|
|
31
|
+
export declare function parseScopeArgs(argv: readonly string[]): ScopeSelectorRaw;
|
|
32
|
+
export declare function resolveScopeSelection(raw: ScopeSelectorRaw, managed: readonly AgentId[]): ScopeSelection;
|
|
33
|
+
/** Order-insensitive equality between the scope on disk and the one about to
|
|
34
|
+
* be written, with `undefined` (no explicit scope) distinct from `[]`. */
|
|
35
|
+
export declare function sameScope(current: readonly AgentId[] | undefined, next: readonly AgentId[] | undefined): boolean;
|
|
36
|
+
export declare function describeScope(scope: readonly AgentId[] | undefined, managed: readonly AgentId[]): string;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared agent-scope selection for `skill scope` / `mcp scope`
|
|
3
|
+
* (trellis-scope-editing-and-auth-status design.md D1–D3). Both commands
|
|
4
|
+
* take exactly one of `--agents a,b`, `--all`, `--none`; keeping the parsing,
|
|
5
|
+
* validation and normalization here means a skill and an MCP server can never
|
|
6
|
+
* disagree about what a given selection means.
|
|
7
|
+
*/
|
|
8
|
+
import { ALL_AGENTS } from "../core/types.js";
|
|
9
|
+
export const SCOPE_SELECTOR_USAGE = "exactly one of --agents <ids>, --all, --none is required";
|
|
10
|
+
/** `argv.indexOf`-style reader, same idiom as every other command's flags. */
|
|
11
|
+
export function parseScopeArgs(argv) {
|
|
12
|
+
const i = argv.indexOf("--agents");
|
|
13
|
+
return {
|
|
14
|
+
...(i >= 0 ? { agents: argv[i + 1] ?? "" } : {}),
|
|
15
|
+
all: argv.includes("--all"),
|
|
16
|
+
none: argv.includes("--none"),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function sameSet(a, b) {
|
|
20
|
+
return a.length === b.length && a.every((id) => b.includes(id));
|
|
21
|
+
}
|
|
22
|
+
export function resolveScopeSelection(raw, managed) {
|
|
23
|
+
const chosen = [raw.agents !== undefined, raw.all === true, raw.none === true].filter(Boolean).length;
|
|
24
|
+
if (chosen !== 1)
|
|
25
|
+
return { ok: false, detail: SCOPE_SELECTOR_USAGE };
|
|
26
|
+
if (raw.all)
|
|
27
|
+
return { ok: true, scope: undefined, mode: "all", normalizedFromFull: false };
|
|
28
|
+
if (raw.none)
|
|
29
|
+
return { ok: true, scope: [], mode: "none", normalizedFromFull: false };
|
|
30
|
+
// An empty list is refused rather than read as "nobody": "nobody" is a real
|
|
31
|
+
// choice and has its own spelling, so a blank value is almost always a slip.
|
|
32
|
+
const ids = [...new Set((raw.agents ?? "").split(",").map((s) => s.trim()).filter(Boolean))];
|
|
33
|
+
if (ids.length === 0) {
|
|
34
|
+
return { ok: false, detail: "--agents must list at least one agent id (use --none for no agent at all)" };
|
|
35
|
+
}
|
|
36
|
+
for (const id of ids) {
|
|
37
|
+
if (!ALL_AGENTS.includes(id)) {
|
|
38
|
+
return { ok: false, detail: `--agents "${id}" is not a recognized agent id (${ALL_AGENTS.join(", ")})` };
|
|
39
|
+
}
|
|
40
|
+
// An unmanaged id would be accepted by the data model but never take
|
|
41
|
+
// effect — scope is always intersected with the managed set — so writing
|
|
42
|
+
// it would only leave config that looks like it does something.
|
|
43
|
+
if (!managed.includes(id)) {
|
|
44
|
+
const known = managed.length > 0 ? `managed agents: ${managed.join(", ")}` : "no agents are managed yet — run `trellis onboard` first";
|
|
45
|
+
return { ok: false, detail: `--agents "${id}" is not a managed agent (${known})` };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// Stable on-disk order regardless of how the user typed the list.
|
|
49
|
+
const scope = ALL_AGENTS.filter((id) => ids.includes(id));
|
|
50
|
+
if (sameSet(scope, managed))
|
|
51
|
+
return { ok: true, scope: undefined, mode: "all", normalizedFromFull: true };
|
|
52
|
+
return { ok: true, scope, mode: "explicit", normalizedFromFull: false };
|
|
53
|
+
}
|
|
54
|
+
/** Order-insensitive equality between the scope on disk and the one about to
|
|
55
|
+
* be written, with `undefined` (no explicit scope) distinct from `[]`. */
|
|
56
|
+
export function sameScope(current, next) {
|
|
57
|
+
if (current === undefined || next === undefined)
|
|
58
|
+
return current === next;
|
|
59
|
+
return sameSet(current, next);
|
|
60
|
+
}
|
|
61
|
+
export function describeScope(scope, managed) {
|
|
62
|
+
if (scope === undefined)
|
|
63
|
+
return `every managed agent (${managed.join(", ") || "none managed yet"})`;
|
|
64
|
+
return scope.length > 0 ? scope.join(", ") : "no agent";
|
|
65
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manifest for serving a canonical skill in the shape the Skills-over-MCP
|
|
3
|
+
* extension defines (trellis-skills-over-mcp design.md D2/D3): every file's
|
|
4
|
+
* URI, `sha256:<hex>` digest and byte size, computed from the exact bytes
|
|
5
|
+
* that `resources/read` returns. One reader serves both, so the manifest and
|
|
6
|
+
* the content can never disagree.
|
|
7
|
+
*
|
|
8
|
+
* A digest here means "these bytes match this manifest" — it is not a claim
|
|
9
|
+
* about the content's safety (design.md D5).
|
|
10
|
+
*/
|
|
11
|
+
/** The extension's SHOULD-NOT-exceed limits per skill. */
|
|
12
|
+
export declare const MAX_SKILL_FILES = 512;
|
|
13
|
+
export declare const MAX_SKILL_BYTES: number;
|
|
14
|
+
/** Same per-file bound the existing provider tools enforce. */
|
|
15
|
+
export declare const MAX_SKILL_FILE_BYTES: number;
|
|
16
|
+
export interface SkillManifestFile {
|
|
17
|
+
uri: string;
|
|
18
|
+
digest: string;
|
|
19
|
+
size: number;
|
|
20
|
+
}
|
|
21
|
+
export interface SkillEntry {
|
|
22
|
+
uri: string;
|
|
23
|
+
frontmatter: Record<string, unknown>;
|
|
24
|
+
resources: SkillManifestFile[];
|
|
25
|
+
_meta?: Record<string, unknown>;
|
|
26
|
+
}
|
|
27
|
+
export type SkillBuild = {
|
|
28
|
+
ok: true;
|
|
29
|
+
entry: SkillEntry;
|
|
30
|
+
} | {
|
|
31
|
+
ok: false;
|
|
32
|
+
reason: string;
|
|
33
|
+
};
|
|
34
|
+
export declare function skillRootUri(name: string): string;
|
|
35
|
+
export declare function skillFileUri(name: string, relativePath: string): string;
|
|
36
|
+
/** `skill://<name>/<path>` → parts, or undefined for any other shape. Parsed
|
|
37
|
+
* by hand: `URL` would lowercase the authority and mangle odd names. */
|
|
38
|
+
export declare function parseSkillUri(uri: string): {
|
|
39
|
+
name: string;
|
|
40
|
+
path: string;
|
|
41
|
+
} | undefined;
|
|
42
|
+
export declare function digestOf(bytes: Buffer): string;
|
|
43
|
+
/**
|
|
44
|
+
* The one reader. Refuses secret-shaped paths, anything resolving outside the
|
|
45
|
+
* skill root (symlink escape included), non-files and oversized files; returns
|
|
46
|
+
* the raw bytes otherwise.
|
|
47
|
+
*/
|
|
48
|
+
export declare function readSkillFileBytes(skillDir: string, relativePath: string): Buffer;
|
|
49
|
+
export declare function mimeTypeFor(path: string): string;
|
|
50
|
+
/** Valid UTF-8 round-trips exactly as `text`; anything else must go out as a
|
|
51
|
+
* base64 blob, or the bytes a host verifies would not be the bytes we hashed. */
|
|
52
|
+
export declare function toResourceContent(uri: string, path: string, bytes: Buffer): {
|
|
53
|
+
uri: string;
|
|
54
|
+
mimeType: string;
|
|
55
|
+
text: string;
|
|
56
|
+
} | {
|
|
57
|
+
uri: string;
|
|
58
|
+
mimeType: string;
|
|
59
|
+
blob: string;
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Builds the entry for one skill, or says why it cannot be served. A skill
|
|
63
|
+
* that breaks a limit is omitted whole rather than published with a partial
|
|
64
|
+
* manifest — a partial manifest is a false claim of completeness (D2).
|
|
65
|
+
*/
|
|
66
|
+
export declare function buildSkillEntry(name: string, skillDir: string): SkillBuild;
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manifest for serving a canonical skill in the shape the Skills-over-MCP
|
|
3
|
+
* extension defines (trellis-skills-over-mcp design.md D2/D3): every file's
|
|
4
|
+
* URI, `sha256:<hex>` digest and byte size, computed from the exact bytes
|
|
5
|
+
* that `resources/read` returns. One reader serves both, so the manifest and
|
|
6
|
+
* the content can never disagree.
|
|
7
|
+
*
|
|
8
|
+
* A digest here means "these bytes match this manifest" — it is not a claim
|
|
9
|
+
* about the content's safety (design.md D5).
|
|
10
|
+
*/
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
13
|
+
import { relative, resolve, sep } from "node:path";
|
|
14
|
+
import { parse as parseYaml } from "yaml";
|
|
15
|
+
/** The extension's SHOULD-NOT-exceed limits per skill. */
|
|
16
|
+
export const MAX_SKILL_FILES = 512;
|
|
17
|
+
export const MAX_SKILL_BYTES = 16 * 1024 * 1024;
|
|
18
|
+
/** Same per-file bound the existing provider tools enforce. */
|
|
19
|
+
export const MAX_SKILL_FILE_BYTES = 256 * 1024;
|
|
20
|
+
const SKILL_SCHEME = "skill://";
|
|
21
|
+
/** Agent Skills naming rule: lowercase alphanumerics and single hyphens. It
|
|
22
|
+
* also keeps the name safe as a URI authority, which lowercases. */
|
|
23
|
+
const NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
24
|
+
const SKIPPED_DIRS = new Set(["node_modules"]);
|
|
25
|
+
export function skillRootUri(name) {
|
|
26
|
+
return `${SKILL_SCHEME}${name}/SKILL.md`;
|
|
27
|
+
}
|
|
28
|
+
export function skillFileUri(name, relativePath) {
|
|
29
|
+
return `${SKILL_SCHEME}${name}/${relativePath.split(sep).map(encodeURIComponent).join("/")}`;
|
|
30
|
+
}
|
|
31
|
+
/** `skill://<name>/<path>` → parts, or undefined for any other shape. Parsed
|
|
32
|
+
* by hand: `URL` would lowercase the authority and mangle odd names. */
|
|
33
|
+
export function parseSkillUri(uri) {
|
|
34
|
+
if (!uri.startsWith(SKILL_SCHEME))
|
|
35
|
+
return undefined;
|
|
36
|
+
const rest = uri.slice(SKILL_SCHEME.length);
|
|
37
|
+
const slash = rest.indexOf("/");
|
|
38
|
+
if (slash <= 0)
|
|
39
|
+
return undefined;
|
|
40
|
+
const name = rest.slice(0, slash);
|
|
41
|
+
try {
|
|
42
|
+
const segments = rest.slice(slash + 1).split("/").map(decodeURIComponent);
|
|
43
|
+
if (segments.length === 0 || segments.some((s) => s === "" || s === "." || s === ".."))
|
|
44
|
+
return undefined;
|
|
45
|
+
return { name, path: segments.join("/") };
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function isSecretLikePath(path) {
|
|
52
|
+
const normalized = path.toLowerCase();
|
|
53
|
+
return normalized.includes(".env") || normalized.includes("secret") || normalized.endsWith("servers.local.env");
|
|
54
|
+
}
|
|
55
|
+
export function digestOf(bytes) {
|
|
56
|
+
return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The one reader. Refuses secret-shaped paths, anything resolving outside the
|
|
60
|
+
* skill root (symlink escape included), non-files and oversized files; returns
|
|
61
|
+
* the raw bytes otherwise.
|
|
62
|
+
*/
|
|
63
|
+
export function readSkillFileBytes(skillDir, relativePath) {
|
|
64
|
+
if (relativePath.length === 0 || relativePath.includes("\0") || isSecretLikePath(relativePath)) {
|
|
65
|
+
throw new Error("supporting file path is not allowed");
|
|
66
|
+
}
|
|
67
|
+
const root = realpathSync(skillDir);
|
|
68
|
+
const real = realpathSync(resolve(skillDir, relativePath));
|
|
69
|
+
const rel = relative(root, real);
|
|
70
|
+
if (rel.startsWith(`..${sep}`) || rel === ".." || rel === "")
|
|
71
|
+
throw new Error("file is outside the skill root");
|
|
72
|
+
if (isSecretLikePath(rel))
|
|
73
|
+
throw new Error("secret-like files are not readable through the skill provider");
|
|
74
|
+
const stat = statSync(real);
|
|
75
|
+
if (!stat.isFile())
|
|
76
|
+
throw new Error("not a regular file");
|
|
77
|
+
if (stat.size > MAX_SKILL_FILE_BYTES)
|
|
78
|
+
throw new Error(`file exceeds the ${MAX_SKILL_FILE_BYTES}-byte provider limit`);
|
|
79
|
+
return readFileSync(real);
|
|
80
|
+
}
|
|
81
|
+
export function mimeTypeFor(path) {
|
|
82
|
+
const lower = path.toLowerCase();
|
|
83
|
+
if (lower.endsWith(".md"))
|
|
84
|
+
return "text/markdown";
|
|
85
|
+
if (lower.endsWith(".json"))
|
|
86
|
+
return "application/json";
|
|
87
|
+
if (lower.endsWith(".yaml") || lower.endsWith(".yml"))
|
|
88
|
+
return "application/yaml";
|
|
89
|
+
if (lower.endsWith(".html"))
|
|
90
|
+
return "text/html";
|
|
91
|
+
return "text/plain";
|
|
92
|
+
}
|
|
93
|
+
/** Valid UTF-8 round-trips exactly as `text`; anything else must go out as a
|
|
94
|
+
* base64 blob, or the bytes a host verifies would not be the bytes we hashed. */
|
|
95
|
+
export function toResourceContent(uri, path, bytes) {
|
|
96
|
+
try {
|
|
97
|
+
const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
98
|
+
return { uri, mimeType: mimeTypeFor(path), text };
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return { uri, mimeType: "application/octet-stream", blob: bytes.toString("base64") };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function parseFrontmatter(content) {
|
|
105
|
+
if (!content.startsWith("---"))
|
|
106
|
+
return undefined;
|
|
107
|
+
const end = content.indexOf("\n---", 3);
|
|
108
|
+
if (end < 0)
|
|
109
|
+
return undefined;
|
|
110
|
+
try {
|
|
111
|
+
const parsed = parseYaml(content.slice(3, end));
|
|
112
|
+
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : undefined;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** Walks the skill directory without following links out of it. Stops as soon
|
|
119
|
+
* as a limit is exceeded so a huge tree is never fully traversed. */
|
|
120
|
+
function collectFiles(skillDir) {
|
|
121
|
+
const files = [];
|
|
122
|
+
const stack = [skillDir];
|
|
123
|
+
while (stack.length > 0) {
|
|
124
|
+
const dir = stack.pop();
|
|
125
|
+
for (const name of readdirSync(dir).sort()) {
|
|
126
|
+
if (name.startsWith(".") || SKIPPED_DIRS.has(name))
|
|
127
|
+
continue;
|
|
128
|
+
const full = resolve(dir, name);
|
|
129
|
+
const link = lstatSync(full);
|
|
130
|
+
if (link.isSymbolicLink()) {
|
|
131
|
+
// A link is followed only if it stays inside the root and is a file.
|
|
132
|
+
try {
|
|
133
|
+
const target = realpathSync(full);
|
|
134
|
+
const rel = relative(realpathSync(skillDir), target);
|
|
135
|
+
if (rel.startsWith(`..${sep}`) || rel === ".." || !statSync(target).isFile())
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
else if (link.isDirectory()) {
|
|
143
|
+
stack.push(full);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
else if (!link.isFile()) {
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
files.push(relative(skillDir, full));
|
|
150
|
+
if (files.length > MAX_SKILL_FILES)
|
|
151
|
+
return { tooLarge: `more than ${MAX_SKILL_FILES} files` };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return { files };
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Builds the entry for one skill, or says why it cannot be served. A skill
|
|
158
|
+
* that breaks a limit is omitted whole rather than published with a partial
|
|
159
|
+
* manifest — a partial manifest is a false claim of completeness (D2).
|
|
160
|
+
*/
|
|
161
|
+
export function buildSkillEntry(name, skillDir) {
|
|
162
|
+
if (!NAME_RE.test(name) || name.length > 64) {
|
|
163
|
+
return { ok: false, reason: `"${name}" is not a valid Agent Skills name (lowercase letters, digits and single hyphens, at most 64 characters)` };
|
|
164
|
+
}
|
|
165
|
+
if (!existsSync(resolve(skillDir, "SKILL.md")))
|
|
166
|
+
return { ok: false, reason: `"${name}" has no SKILL.md` };
|
|
167
|
+
let skillMd;
|
|
168
|
+
try {
|
|
169
|
+
skillMd = readSkillFileBytes(skillDir, "SKILL.md");
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
return { ok: false, reason: `"${name}" SKILL.md cannot be served: ${err instanceof Error ? err.message : String(err)}` };
|
|
173
|
+
}
|
|
174
|
+
const frontmatter = parseFrontmatter(skillMd.toString("utf8"));
|
|
175
|
+
if (!frontmatter || typeof frontmatter.name !== "string" || typeof frontmatter.description !== "string" || frontmatter.description.length === 0) {
|
|
176
|
+
return { ok: false, reason: `"${name}" SKILL.md frontmatter needs a string name and a non-empty description` };
|
|
177
|
+
}
|
|
178
|
+
if (frontmatter.name !== name) {
|
|
179
|
+
return { ok: false, reason: `"${name}" directory name does not match its frontmatter name "${frontmatter.name}"` };
|
|
180
|
+
}
|
|
181
|
+
const collected = collectFiles(skillDir);
|
|
182
|
+
if ("tooLarge" in collected)
|
|
183
|
+
return { ok: false, reason: `"${name}" exceeds the extension's size limit (${collected.tooLarge})` };
|
|
184
|
+
const resources = [];
|
|
185
|
+
let total = 0;
|
|
186
|
+
for (const rel of collected.files) {
|
|
187
|
+
let bytes;
|
|
188
|
+
try {
|
|
189
|
+
bytes = readSkillFileBytes(skillDir, rel);
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
continue; // refused by the reader: absent from the manifest and from reads alike
|
|
193
|
+
}
|
|
194
|
+
total += bytes.length;
|
|
195
|
+
if (total > MAX_SKILL_BYTES)
|
|
196
|
+
return { ok: false, reason: `"${name}" exceeds the extension's size limit (more than ${MAX_SKILL_BYTES} bytes)` };
|
|
197
|
+
resources.push({ uri: skillFileUri(name, rel), digest: digestOf(bytes), size: bytes.length });
|
|
198
|
+
}
|
|
199
|
+
if (!resources.some((file) => file.uri === skillRootUri(name))) {
|
|
200
|
+
return { ok: false, reason: `"${name}" SKILL.md is missing from its own manifest` };
|
|
201
|
+
}
|
|
202
|
+
return { ok: true, entry: { uri: skillRootUri(name), frontmatter, resources } };
|
|
203
|
+
}
|
|
@@ -1,11 +1,31 @@
|
|
|
1
|
-
import type { Resource, ReadResourceResult, Tool, CallToolResult } from "@modelcontextprotocol/
|
|
1
|
+
import type { Resource, ReadResourceResult, Tool, CallToolResult } from "@modelcontextprotocol/server";
|
|
2
2
|
import type { RuntimeContext, TrellisProvider } from "./mcpRuntime.js";
|
|
3
|
+
import { type SkillEntry } from "./skillManifest.js";
|
|
3
4
|
export declare class SkillProvider implements TrellisProvider {
|
|
5
|
+
private readonly onDiagnostic;
|
|
4
6
|
readonly id = "skills";
|
|
7
|
+
/** Each omitted skill is reported once per process, not on every request. */
|
|
8
|
+
private readonly reported;
|
|
9
|
+
constructor(onDiagnostic?: (message: string) => void);
|
|
10
|
+
/**
|
|
11
|
+
* The skills this agent may be served under `skill://`, each with a manifest
|
|
12
|
+
* computed from the bytes it would read (trellis-skills-over-mcp D2/D3). A
|
|
13
|
+
* skill that cannot be served validly is omitted and reported, never served
|
|
14
|
+
* under a URI the specification calls invalid.
|
|
15
|
+
*/
|
|
16
|
+
private catalog;
|
|
17
|
+
/** `skills/list` — one entry per servable, in-scope skill. */
|
|
18
|
+
listSkillEntries(context: RuntimeContext): SkillEntry[];
|
|
19
|
+
/** `skills/get` — lookup by URI, independent of listing. Out-of-scope and
|
|
20
|
+
* unknown URIs are indistinguishable by design. */
|
|
21
|
+
getSkillEntry(uri: string, context: RuntimeContext): SkillEntry | undefined;
|
|
5
22
|
listTools(): Tool[];
|
|
6
23
|
callTool(name: string, args: unknown, context: RuntimeContext): Promise<CallToolResult>;
|
|
7
24
|
listResources(context: RuntimeContext): Resource[];
|
|
8
25
|
readResource(uri: URL, context: RuntimeContext): ReadResourceResult;
|
|
26
|
+
/** Serves only what the manifest advertises, from the same reader that
|
|
27
|
+
* computed its digests — the two cannot disagree. */
|
|
28
|
+
private readConventionalResource;
|
|
9
29
|
private search;
|
|
10
30
|
private readSkill;
|
|
11
31
|
private readFile;
|