@sigrun/ext-mcp 0.0.0-stage → 0.1.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/CHANGELOG.md +27 -0
- package/LICENSE +21 -0
- package/README.md +94 -2
- package/dist/bin.d.ts +6 -0
- package/dist/bin.js +9 -0
- package/dist/cli.d.ts +29 -0
- package/dist/cli.js +109 -0
- package/dist/client.d.ts +48 -0
- package/dist/client.js +279 -0
- package/dist/config.d.ts +91 -0
- package/dist/config.js +237 -0
- package/dist/extension.d.ts +51 -0
- package/dist/extension.js +209 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +13 -0
- package/dist/oauth.d.ts +60 -0
- package/dist/oauth.js +238 -0
- package/dist/section.d.ts +58 -0
- package/dist/section.js +92 -0
- package/dist/tokens.d.ts +55 -0
- package/dist/tokens.js +118 -0
- package/dist/types.d.ts +28 -0
- package/dist/types.js +2 -0
- package/package.json +58 -5
package/dist/oauth.d.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The OAuth authorization-code flow (T3c): paste-the-URL mode for stdio MCP
|
|
3
|
+
* servers living where the user's browser cannot reach them (inside a pod).
|
|
4
|
+
*
|
|
5
|
+
* `startOauth` builds the authorize URL (with PKCE S256 — RFC 7636; the
|
|
6
|
+
* verifier is generated with node:crypto, no new deps), starts a loopback
|
|
7
|
+
* listener on 127.0.0.1 with a RANDOM port (RFC 8252) as redirect_uri, and
|
|
8
|
+
* returns the URL for the user to open however they get there. The CLI also
|
|
9
|
+
* prints the redirect_uri and reads pasted redirected URLs from stdin —
|
|
10
|
+
* first arrival wins: the loopback hit OR the paste. The code is exchanged
|
|
11
|
+
* at `tokenUrl` (POST, client_id, grant_type=authorization_code, code,
|
|
12
|
+
* redirect_uri, code_verifier) and the token lands in the store.
|
|
13
|
+
*/
|
|
14
|
+
import type { McpOAuthConfig } from "./config.js";
|
|
15
|
+
import { type McpStoredToken } from "./tokens.js";
|
|
16
|
+
/** The paste-the-URL flow's interactive handles (what `runOauth` returns). */
|
|
17
|
+
export interface OauthFlow {
|
|
18
|
+
/** The authorize URL to open in a browser (also printed by the CLI). */
|
|
19
|
+
readonly authorizeUrl: string;
|
|
20
|
+
/** The loopback redirect_uri (`http://127.0.0.1:<port>/callback`). */
|
|
21
|
+
readonly redirectUri: string;
|
|
22
|
+
/** Feed a pasted redirected URL (the paste fallback). Extracts the code;
|
|
23
|
+
* a URL with no code is IGNORED (the user may paste again); a URL
|
|
24
|
+
* carrying an AS `error` parameter FAILS the flow fast. */
|
|
25
|
+
pasteUrl(url: string): void;
|
|
26
|
+
/** Settles with the STORED token, or rejects on AS error / exchange
|
|
27
|
+
* failure / timeout. */
|
|
28
|
+
readonly done: Promise<McpStoredToken>;
|
|
29
|
+
/** Tear down the listener (idempotent; no-op after the flow settles). */
|
|
30
|
+
stop(): void;
|
|
31
|
+
}
|
|
32
|
+
/** Extract `code` (and `error`) from a redirected URL's query string. */
|
|
33
|
+
export declare function extractCode(url: string): {
|
|
34
|
+
code: string;
|
|
35
|
+
} | {
|
|
36
|
+
error: string;
|
|
37
|
+
};
|
|
38
|
+
/** Exchange the code at `tokenUrl` and return the normalized token. */
|
|
39
|
+
export declare function exchangeCode(oauth: McpOAuthConfig, code: string, redirectUri: string, verifier: string): Promise<McpStoredToken>;
|
|
40
|
+
/** An injected seam for tests: how the CLI/flow mints the verifier. */
|
|
41
|
+
export declare function makePkcePair(): {
|
|
42
|
+
readonly verifier: string;
|
|
43
|
+
readonly challenge: string;
|
|
44
|
+
};
|
|
45
|
+
/** Build the authorize URL (PKCE S256 + optional scopes + redirect_uri). */
|
|
46
|
+
export declare function buildAuthorizeUrl(oauth: McpOAuthConfig, redirectUri: string, codeChallenge: string): string;
|
|
47
|
+
/** Oauth start options (defaults match the CLI: 5-minute timeout). */
|
|
48
|
+
export interface StartOauthOptions {
|
|
49
|
+
/** How long to wait for a code (listener or paste) before failing. */
|
|
50
|
+
readonly timeoutMs?: number;
|
|
51
|
+
/** Where to store the token (default: beside the resolved config). */
|
|
52
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Run the flow for one server (the CLI-equivalent `runOauth`): start the
|
|
56
|
+
* loopback listener, build + return the authorize URL, and expose
|
|
57
|
+
* `pasteUrl` for the paste-the-URL fallback. First arrival wins; the flow
|
|
58
|
+
* resolves with the STORED token (written to the token store).
|
|
59
|
+
*/
|
|
60
|
+
export declare function startOauth(serverName: string, oauth: McpOAuthConfig, options?: StartOauthOptions): Promise<OauthFlow>;
|
package/dist/oauth.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The OAuth authorization-code flow (T3c): paste-the-URL mode for stdio MCP
|
|
3
|
+
* servers living where the user's browser cannot reach them (inside a pod).
|
|
4
|
+
*
|
|
5
|
+
* `startOauth` builds the authorize URL (with PKCE S256 — RFC 7636; the
|
|
6
|
+
* verifier is generated with node:crypto, no new deps), starts a loopback
|
|
7
|
+
* listener on 127.0.0.1 with a RANDOM port (RFC 8252) as redirect_uri, and
|
|
8
|
+
* returns the URL for the user to open however they get there. The CLI also
|
|
9
|
+
* prints the redirect_uri and reads pasted redirected URLs from stdin —
|
|
10
|
+
* first arrival wins: the loopback hit OR the paste. The code is exchanged
|
|
11
|
+
* at `tokenUrl` (POST, client_id, grant_type=authorization_code, code,
|
|
12
|
+
* redirect_uri, code_verifier) and the token lands in the store.
|
|
13
|
+
*/
|
|
14
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
15
|
+
import { request as httpRequest } from "node:http";
|
|
16
|
+
import { request as httpsRequest } from "node:https";
|
|
17
|
+
import { storeMcpToken } from "./tokens.js";
|
|
18
|
+
/** Extract `code` (and `error`) from a redirected URL's query string. */
|
|
19
|
+
export function extractCode(url) {
|
|
20
|
+
const query = url.slice(url.indexOf("?") + 1);
|
|
21
|
+
for (const pair of query.split("&")) {
|
|
22
|
+
const eq = pair.indexOf("=");
|
|
23
|
+
if (eq < 0)
|
|
24
|
+
continue;
|
|
25
|
+
const key = decodeURIComponent(pair.slice(0, eq));
|
|
26
|
+
const value = decodeURIComponent(pair.slice(eq + 1));
|
|
27
|
+
if (key === "code")
|
|
28
|
+
return { code: value };
|
|
29
|
+
if (key === "error")
|
|
30
|
+
return { error: value };
|
|
31
|
+
}
|
|
32
|
+
throw new Error(`redirected URL has no code in its query: ${url}`);
|
|
33
|
+
}
|
|
34
|
+
/** Base64url (RFC 7636 §2) — used for the PKCE pair. */
|
|
35
|
+
function b64url(bytes) {
|
|
36
|
+
return bytes
|
|
37
|
+
.toString("base64")
|
|
38
|
+
.replace(/=+$/, "")
|
|
39
|
+
.replace(/\+/g, "-")
|
|
40
|
+
.replace(/\//g, "_");
|
|
41
|
+
}
|
|
42
|
+
/** POST form-encoded params to `url`, resolve the parsed JSON body. */
|
|
43
|
+
async function postJson(url, params) {
|
|
44
|
+
const body = Object.entries(params)
|
|
45
|
+
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
|
46
|
+
.join("&");
|
|
47
|
+
const target = new URL(url);
|
|
48
|
+
const send = target.protocol === "https:" ? httpsRequest : httpRequest;
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
const req = send({
|
|
51
|
+
hostname: target.hostname,
|
|
52
|
+
port: target.port,
|
|
53
|
+
path: `${target.pathname}${target.search}`,
|
|
54
|
+
method: "POST",
|
|
55
|
+
headers: {
|
|
56
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
57
|
+
"content-length": String(Buffer.byteLength(body)),
|
|
58
|
+
},
|
|
59
|
+
}, (res) => {
|
|
60
|
+
const chunks = [];
|
|
61
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
62
|
+
res.on("end", () => {
|
|
63
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
64
|
+
if ((res.statusCode ?? 0) < 200 || (res.statusCode ?? 0) >= 300) {
|
|
65
|
+
reject(new Error(`token endpoint ${url} returned ${res.statusCode}: ${text}`));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
const parsed = JSON.parse(text);
|
|
70
|
+
if (typeof parsed !== "object" ||
|
|
71
|
+
parsed === null ||
|
|
72
|
+
Array.isArray(parsed)) {
|
|
73
|
+
throw new Error("not an object");
|
|
74
|
+
}
|
|
75
|
+
resolve(parsed);
|
|
76
|
+
}
|
|
77
|
+
catch (cause) {
|
|
78
|
+
reject(new Error(`token endpoint ${url} returned non-JSON: ${cause instanceof Error ? cause.message : String(cause)}`));
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
req.on("error", reject);
|
|
83
|
+
req.end(body);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
/** Exchange the code at `tokenUrl` and return the normalized token. */
|
|
87
|
+
export async function exchangeCode(oauth, code, redirectUri, verifier) {
|
|
88
|
+
const response = await postJson(oauth.tokenUrl, {
|
|
89
|
+
client_id: oauth.clientId,
|
|
90
|
+
grant_type: "authorization_code",
|
|
91
|
+
code,
|
|
92
|
+
redirect_uri: redirectUri,
|
|
93
|
+
code_verifier: verifier,
|
|
94
|
+
});
|
|
95
|
+
if (typeof response.access_token !== "string" ||
|
|
96
|
+
typeof response.token_type !== "string") {
|
|
97
|
+
throw new Error(`token endpoint response lacks access_token/token_type: ${JSON.stringify(response)}`);
|
|
98
|
+
}
|
|
99
|
+
const now = Math.floor(Date.now() / 1000);
|
|
100
|
+
return {
|
|
101
|
+
access_token: response.access_token,
|
|
102
|
+
token_type: response.token_type,
|
|
103
|
+
...(typeof response.refresh_token === "string"
|
|
104
|
+
? { refresh_token: response.refresh_token }
|
|
105
|
+
: {}),
|
|
106
|
+
...(typeof response.expires_in === "number"
|
|
107
|
+
? { expires_at: now + response.expires_in }
|
|
108
|
+
: {}),
|
|
109
|
+
obtained_at: now,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/** An injected seam for tests: how the CLI/flow mints the verifier. */
|
|
113
|
+
export function makePkcePair() {
|
|
114
|
+
const verifier = b64url(randomBytes(32));
|
|
115
|
+
const challenge = b64url(createHash("sha256").update(verifier).digest());
|
|
116
|
+
return { verifier, challenge };
|
|
117
|
+
}
|
|
118
|
+
/** Build the authorize URL (PKCE S256 + optional scopes + redirect_uri). */
|
|
119
|
+
export function buildAuthorizeUrl(oauth, redirectUri, codeChallenge) {
|
|
120
|
+
const url = new URL(oauth.authorizeUrl);
|
|
121
|
+
url.searchParams.set("client_id", oauth.clientId);
|
|
122
|
+
url.searchParams.set("redirect_uri", redirectUri);
|
|
123
|
+
url.searchParams.set("response_type", "code");
|
|
124
|
+
url.searchParams.set("code_challenge", codeChallenge);
|
|
125
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
126
|
+
if (oauth.scopes !== undefined) {
|
|
127
|
+
url.searchParams.set("scope", oauth.scopes.join(" "));
|
|
128
|
+
}
|
|
129
|
+
return url.toString();
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Run the flow for one server (the CLI-equivalent `runOauth`): start the
|
|
133
|
+
* loopback listener, build + return the authorize URL, and expose
|
|
134
|
+
* `pasteUrl` for the paste-the-URL fallback. First arrival wins; the flow
|
|
135
|
+
* resolves with the STORED token (written to the token store).
|
|
136
|
+
*/
|
|
137
|
+
export async function startOauth(serverName, oauth, options = {}) {
|
|
138
|
+
const { createServer } = await import("node:http");
|
|
139
|
+
const { verifier, challenge } = makePkcePair();
|
|
140
|
+
// The code settlement: the listener hit and the paste handler both feed
|
|
141
|
+
// this seam; the first caller wins (later calls are ignored).
|
|
142
|
+
let settleCode;
|
|
143
|
+
let failFlow;
|
|
144
|
+
const codeReceived = new Promise((resolve, reject) => {
|
|
145
|
+
settleCode = resolve;
|
|
146
|
+
failFlow = reject;
|
|
147
|
+
});
|
|
148
|
+
// Loopback listener (RFC 8252): random port on 127.0.0.1, one route.
|
|
149
|
+
const server = createServer((req, res) => {
|
|
150
|
+
const url = req.url ?? "/";
|
|
151
|
+
let code;
|
|
152
|
+
let oauthError;
|
|
153
|
+
for (const pair of url.slice(url.indexOf("?") + 1).split("&")) {
|
|
154
|
+
const eq = pair.indexOf("=");
|
|
155
|
+
if (eq < 0)
|
|
156
|
+
continue;
|
|
157
|
+
const [key, value] = [
|
|
158
|
+
pair.slice(0, eq),
|
|
159
|
+
decodeURIComponent(pair.slice(eq + 1)),
|
|
160
|
+
];
|
|
161
|
+
if (key === "code")
|
|
162
|
+
code = value;
|
|
163
|
+
if (key === "error")
|
|
164
|
+
oauthError = value;
|
|
165
|
+
}
|
|
166
|
+
res.writeHead(200, { "content-type": "text/plain" });
|
|
167
|
+
if (oauthError !== undefined) {
|
|
168
|
+
res.end(`Authorization failed: ${oauthError}`);
|
|
169
|
+
failFlow?.(new Error(`authorization server returned error: ${oauthError}`));
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (code !== undefined) {
|
|
173
|
+
res.end("Authorization complete. You can close this tab.");
|
|
174
|
+
settleCode?.(code);
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
res.end("Missing code in the redirect.");
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
const listening = new Promise((resolve, reject) => {
|
|
181
|
+
server.once("error", reject);
|
|
182
|
+
server.listen(0, "127.0.0.1", () => {
|
|
183
|
+
const address = server.address();
|
|
184
|
+
if (address !== null && typeof address === "object") {
|
|
185
|
+
resolve(address.port);
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
reject(new Error("loopback listener did not report a port"));
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
const port = await listening;
|
|
193
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
194
|
+
const authorizeUrl = buildAuthorizeUrl(oauth, redirectUri, challenge);
|
|
195
|
+
const timeoutMs = options.timeoutMs ?? 5 * 60_000;
|
|
196
|
+
// The flow: code (listener-or-paste, or timeout) → exchange → store.
|
|
197
|
+
const done = (async () => {
|
|
198
|
+
const code = await Promise.race([
|
|
199
|
+
codeReceived,
|
|
200
|
+
new Promise((_, reject) => {
|
|
201
|
+
setTimeout(() => reject(new Error(`oauth for "${serverName}" timed out after ${timeoutMs / 1000}s waiting for the redirect (listener or paste)`)), timeoutMs).unref?.();
|
|
202
|
+
}),
|
|
203
|
+
]);
|
|
204
|
+
const token = await exchangeCode(oauth, code, redirectUri, verifier);
|
|
205
|
+
storeMcpToken(serverName, token, options.env);
|
|
206
|
+
return token;
|
|
207
|
+
})();
|
|
208
|
+
// Settle closes the listener no matter which path won. NOTE: the rejection
|
|
209
|
+
// is HANDLED here (both callbacks), so the derived promise never becomes an
|
|
210
|
+
// unhandled rejection when the flow fails.
|
|
211
|
+
const stop = () => server.close();
|
|
212
|
+
void done.then(stop, stop);
|
|
213
|
+
return {
|
|
214
|
+
authorizeUrl,
|
|
215
|
+
redirectUri,
|
|
216
|
+
pasteUrl(url) {
|
|
217
|
+
let extracted;
|
|
218
|
+
try {
|
|
219
|
+
extracted = extractCode(url);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
// A pasted line with no code (a stray paste, a log line): ignore —
|
|
223
|
+
// the user may paste the right URL next. Never throw: this runs in
|
|
224
|
+
// the CLI's readline handler, where a throw would crash the bin.
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if ("error" in extracted) {
|
|
228
|
+
// The AS redirected with an error parameter: fail fast instead
|
|
229
|
+
// of hanging until the timeout.
|
|
230
|
+
failFlow?.(new Error(`authorization server returned error: ${extracted.error}`));
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
settleCode?.(extracted.code);
|
|
234
|
+
},
|
|
235
|
+
done,
|
|
236
|
+
stop,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `mcp_servers` prompt section (spec slice 3): lists every enabled server
|
|
3
|
+
* with codemode/deferred tools — name, description, and HOW its tools are
|
|
4
|
+
* reached (`tool_search` loads deferred tools; codemode scripts call
|
|
5
|
+
* codemode-exposed ones).
|
|
6
|
+
*
|
|
7
|
+
* Gating (rev 3's corrected channel — extensions have no boot hook, no
|
|
8
|
+
* registry visibility, no args; the section render is the only per-request
|
|
9
|
+
* seam): it reads `PromptInput.agent` at RENDER time. A server appears ONLY
|
|
10
|
+
* when its discovery tool is model-offered in THIS request (`codemode` for
|
|
11
|
+
* servers whose tools are script-reachable, `tool_search` for servers with
|
|
12
|
+
* deferred tools — a server with both needs at least one of them). When
|
|
13
|
+
* servers exist whose tools are unreachable through the offered set, one
|
|
14
|
+
* notice line says so. Per-request, de-duplicated by construction, visible
|
|
15
|
+
* to the model where it matters.
|
|
16
|
+
*
|
|
17
|
+
* Budget: the skills-section precedent — 4KB total including the truncation
|
|
18
|
+
* notice, whole lines drop from the end until the text fits, the notice
|
|
19
|
+
* names how many servers were cut.
|
|
20
|
+
*/
|
|
21
|
+
/** The section key the server list renders under (Task 7 asserts this). */
|
|
22
|
+
export declare const MCP_SERVERS_SECTION_KEY = "mcp_servers";
|
|
23
|
+
/** Total rendered-section budget (skills-section precedent), notice included. */
|
|
24
|
+
export declare const MCP_SERVERS_SECTION_LIMIT_BYTES = 4096;
|
|
25
|
+
/** One server's section entry (built at render time from live agent state). */
|
|
26
|
+
export interface McpServerSectionEntry {
|
|
27
|
+
/** The configured server name (its tools register as `<name>/<tool>`). */
|
|
28
|
+
readonly name: string;
|
|
29
|
+
/** One line describing the server (from config or the connection). */
|
|
30
|
+
readonly description: string;
|
|
31
|
+
/** How the tools are reached: "codemode scripts" / "the tool_search tool". */
|
|
32
|
+
readonly how: string;
|
|
33
|
+
}
|
|
34
|
+
/** Which discovery tool a server's exposure needs to be listed. */
|
|
35
|
+
export type Reach = "codemode" | "tool_search" | "either";
|
|
36
|
+
/**
|
|
37
|
+
* Assemble the section text: the header plus one
|
|
38
|
+
* `- name: description (reachable via ...)` line per server, capped at
|
|
39
|
+
* {@link MCP_SERVERS_SECTION_LIMIT_BYTES} bytes including the truncation
|
|
40
|
+
* notice. Whole lines drop from the end until the text fits; the notice
|
|
41
|
+
* names how many servers were cut. Zero servers and zero unreachable
|
|
42
|
+
* render no section at all.
|
|
43
|
+
*/
|
|
44
|
+
export declare function renderMcpServersSection(entries: readonly McpServerSectionEntry[], unreachableCount: number): string | undefined;
|
|
45
|
+
/**
|
|
46
|
+
* Partition live agent state for the section render: which configured
|
|
47
|
+
* codemode/deferred servers are LISTED (their discovery tool is offered in
|
|
48
|
+
* this request) and how many are unreachable (for the notice line).
|
|
49
|
+
*
|
|
50
|
+
* `offers` names the model-offered tool names in this request (from
|
|
51
|
+
* `PromptInput.agent.tools`); `reaches` maps each configured server name to
|
|
52
|
+
* the exposure mix its REGISTERED tools use (the extension computes this
|
|
53
|
+
* snapshot from its live registrations).
|
|
54
|
+
*/
|
|
55
|
+
export declare function partitionForSection(serverReaches: ReadonlyMap<string, Reach>, offers: ReadonlySet<string>): {
|
|
56
|
+
listed: string[];
|
|
57
|
+
unreachable: string[];
|
|
58
|
+
};
|
package/dist/section.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `mcp_servers` prompt section (spec slice 3): lists every enabled server
|
|
3
|
+
* with codemode/deferred tools — name, description, and HOW its tools are
|
|
4
|
+
* reached (`tool_search` loads deferred tools; codemode scripts call
|
|
5
|
+
* codemode-exposed ones).
|
|
6
|
+
*
|
|
7
|
+
* Gating (rev 3's corrected channel — extensions have no boot hook, no
|
|
8
|
+
* registry visibility, no args; the section render is the only per-request
|
|
9
|
+
* seam): it reads `PromptInput.agent` at RENDER time. A server appears ONLY
|
|
10
|
+
* when its discovery tool is model-offered in THIS request (`codemode` for
|
|
11
|
+
* servers whose tools are script-reachable, `tool_search` for servers with
|
|
12
|
+
* deferred tools — a server with both needs at least one of them). When
|
|
13
|
+
* servers exist whose tools are unreachable through the offered set, one
|
|
14
|
+
* notice line says so. Per-request, de-duplicated by construction, visible
|
|
15
|
+
* to the model where it matters.
|
|
16
|
+
*
|
|
17
|
+
* Budget: the skills-section precedent — 4KB total including the truncation
|
|
18
|
+
* notice, whole lines drop from the end until the text fits, the notice
|
|
19
|
+
* names how many servers were cut.
|
|
20
|
+
*/
|
|
21
|
+
/** The section key the server list renders under (Task 7 asserts this). */
|
|
22
|
+
export const MCP_SERVERS_SECTION_KEY = "mcp_servers";
|
|
23
|
+
/** Total rendered-section budget (skills-section precedent), notice included. */
|
|
24
|
+
export const MCP_SERVERS_SECTION_LIMIT_BYTES = 4096;
|
|
25
|
+
const SECTION_HEADER = "MCP servers with script-only or deferred tools. Tools on these servers are NOT offered to you directly:";
|
|
26
|
+
const ENABLE_NOTICE = (count) => `(${count} server${count === 1 ? " has" : "s have"} script-only or deferred tools; enable codemode/tool_search in Settings → default tools to reach them)`;
|
|
27
|
+
function byteLength(text) {
|
|
28
|
+
return new TextEncoder().encode(text).length;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Assemble the section text: the header plus one
|
|
32
|
+
* `- name: description (reachable via ...)` line per server, capped at
|
|
33
|
+
* {@link MCP_SERVERS_SECTION_LIMIT_BYTES} bytes including the truncation
|
|
34
|
+
* notice. Whole lines drop from the end until the text fits; the notice
|
|
35
|
+
* names how many servers were cut. Zero servers and zero unreachable
|
|
36
|
+
* render no section at all.
|
|
37
|
+
*/
|
|
38
|
+
export function renderMcpServersSection(entries, unreachableCount) {
|
|
39
|
+
if (entries.length === 0 && unreachableCount === 0)
|
|
40
|
+
return undefined;
|
|
41
|
+
// The unreachable-server notice always appends (count only).
|
|
42
|
+
const notice = unreachableCount === 0
|
|
43
|
+
? ""
|
|
44
|
+
: `\n${ENABLE_NOTICE(unreachableCount)}`;
|
|
45
|
+
if (entries.length === 0) {
|
|
46
|
+
return notice.replace(/^\n/, "");
|
|
47
|
+
}
|
|
48
|
+
const header = `${SECTION_HEADER}\n\n`;
|
|
49
|
+
const lines = entries.map((entry) => `- ${entry.name}: ${entry.description} (reachable via ${entry.how})`);
|
|
50
|
+
const body = header + lines.join("\n");
|
|
51
|
+
if (byteLength(body + notice) <= MCP_SERVERS_SECTION_LIMIT_BYTES) {
|
|
52
|
+
return body + notice;
|
|
53
|
+
}
|
|
54
|
+
// Over budget: drop whole lines from the end until it fits (the skills
|
|
55
|
+
// section's idiom); the truncation notice names how many were cut.
|
|
56
|
+
let kept = lines.length - 1;
|
|
57
|
+
while (kept >= 0) {
|
|
58
|
+
const dropped = lines.length - kept;
|
|
59
|
+
const truncation = `\n(server list truncated: ${dropped} more not shown)`;
|
|
60
|
+
const text = header + lines.slice(0, kept).join("\n") + truncation;
|
|
61
|
+
if (byteLength(text + notice) <= MCP_SERVERS_SECTION_LIMIT_BYTES) {
|
|
62
|
+
return text + notice;
|
|
63
|
+
}
|
|
64
|
+
kept -= 1;
|
|
65
|
+
}
|
|
66
|
+
// The header alone always fits (reached only if a header change ever
|
|
67
|
+
// grows past the budget).
|
|
68
|
+
return header.replace(/\s+$/, "");
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Partition live agent state for the section render: which configured
|
|
72
|
+
* codemode/deferred servers are LISTED (their discovery tool is offered in
|
|
73
|
+
* this request) and how many are unreachable (for the notice line).
|
|
74
|
+
*
|
|
75
|
+
* `offers` names the model-offered tool names in this request (from
|
|
76
|
+
* `PromptInput.agent.tools`); `reaches` maps each configured server name to
|
|
77
|
+
* the exposure mix its REGISTERED tools use (the extension computes this
|
|
78
|
+
* snapshot from its live registrations).
|
|
79
|
+
*/
|
|
80
|
+
export function partitionForSection(serverReaches, offers) {
|
|
81
|
+
const listed = [];
|
|
82
|
+
const unreachable = [];
|
|
83
|
+
for (const [name, reach] of serverReaches) {
|
|
84
|
+
const ok = reach === "codemode"
|
|
85
|
+
? offers.has("codemode")
|
|
86
|
+
: reach === "tool_search"
|
|
87
|
+
? offers.has("tool_search")
|
|
88
|
+
: offers.has("codemode") || offers.has("tool_search");
|
|
89
|
+
(ok ? listed : unreachable).push(name);
|
|
90
|
+
}
|
|
91
|
+
return { listed, unreachable };
|
|
92
|
+
}
|
package/dist/tokens.d.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The OAuth token store (ruling 2): a JSON file BESIDE the resolved mcp.json
|
|
3
|
+
* — `<resolved-config-dir>/mcp-tokens.json` — so the `SIGRUN_MCP_CONFIG`
|
|
4
|
+
* override relocates tokens with config (a test / pod override moves both).
|
|
5
|
+
*
|
|
6
|
+
* Shape (one entry per configured server name):
|
|
7
|
+
* { "<server-name>": {
|
|
8
|
+
* "access_token": string, "token_type": string,
|
|
9
|
+
* "refresh_token"?: string, "expires_at"?: number, "obtained_at": number
|
|
10
|
+
* } }
|
|
11
|
+
*
|
|
12
|
+
* Files are written 0600 (secrets). A missing file is an empty store; an
|
|
13
|
+
* existing-but-malformed file is a fail-loud config error (consistent with
|
|
14
|
+
* the config store).
|
|
15
|
+
*/
|
|
16
|
+
/** One stored token (the AS's token-endpoint response, normalized). */
|
|
17
|
+
export interface McpStoredToken {
|
|
18
|
+
readonly access_token: string;
|
|
19
|
+
readonly token_type: string;
|
|
20
|
+
readonly refresh_token?: string;
|
|
21
|
+
readonly expires_at?: number;
|
|
22
|
+
readonly obtained_at: number;
|
|
23
|
+
}
|
|
24
|
+
/** The whole token store: server name → token. */
|
|
25
|
+
export type McpTokenStore = Record<string, McpStoredToken>;
|
|
26
|
+
/** Resolve the token-store path for a given environment (beside config). */
|
|
27
|
+
export declare function resolveMcpTokenPath(env?: NodeJS.ProcessEnv): string;
|
|
28
|
+
/** Parse and validate a token-store body. */
|
|
29
|
+
export declare function parseMcpTokenStore(text: string, source: string): McpTokenStore;
|
|
30
|
+
/** Load the token store from beside the resolved config (missing = empty). */
|
|
31
|
+
export declare function loadMcpTokens(env?: NodeJS.ProcessEnv): McpTokenStore;
|
|
32
|
+
/** Persist the store: 0600, beside the resolved config (create-on-write —
|
|
33
|
+
* the config dir is expected to exist since the config file lives there). */
|
|
34
|
+
export declare function saveMcpTokens(store: McpTokenStore, env?: NodeJS.ProcessEnv): void;
|
|
35
|
+
/** Store one server's token (merge over the existing store). */
|
|
36
|
+
export declare function storeMcpToken(name: string, token: McpStoredToken, env?: NodeJS.ProcessEnv): McpTokenStore;
|
|
37
|
+
/**
|
|
38
|
+
* The env var a stored token is injected as (ruling 3): stdio MCP servers
|
|
39
|
+
* take credentials through env, not HTTP headers. A server with both an
|
|
40
|
+
* oauth block and a stored token spawns with this set (merged OVER the
|
|
41
|
+
* server's own `env`, so config wins over inheritance).
|
|
42
|
+
*/
|
|
43
|
+
export declare const MCP_ACCESS_TOKEN_ENV = "MCP_ACCESS_TOKEN";
|
|
44
|
+
/**
|
|
45
|
+
* The spawn env for one server: the server's config env plus the stored
|
|
46
|
+
* token (as {@link MCP_ACCESS_TOKEN_ENV}) when the server has an oauth
|
|
47
|
+
* block AND a stored token. No oauth block, or no token yet (the user has
|
|
48
|
+
* not run `sigrun-ext-mcp oauth <name>`) → the server's env unchanged.
|
|
49
|
+
*/
|
|
50
|
+
export declare function spawnEnvWithToken(name: string, server: {
|
|
51
|
+
readonly env?: Readonly<Record<string, string>>;
|
|
52
|
+
readonly oauth?: {
|
|
53
|
+
readonly clientId: string;
|
|
54
|
+
};
|
|
55
|
+
}, env?: NodeJS.ProcessEnv): Record<string, string>;
|
package/dist/tokens.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The OAuth token store (ruling 2): a JSON file BESIDE the resolved mcp.json
|
|
3
|
+
* — `<resolved-config-dir>/mcp-tokens.json` — so the `SIGRUN_MCP_CONFIG`
|
|
4
|
+
* override relocates tokens with config (a test / pod override moves both).
|
|
5
|
+
*
|
|
6
|
+
* Shape (one entry per configured server name):
|
|
7
|
+
* { "<server-name>": {
|
|
8
|
+
* "access_token": string, "token_type": string,
|
|
9
|
+
* "refresh_token"?: string, "expires_at"?: number, "obtained_at": number
|
|
10
|
+
* } }
|
|
11
|
+
*
|
|
12
|
+
* Files are written 0600 (secrets). A missing file is an empty store; an
|
|
13
|
+
* existing-but-malformed file is a fail-loud config error (consistent with
|
|
14
|
+
* the config store).
|
|
15
|
+
*/
|
|
16
|
+
import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
import { resolveMcpConfigPath } from "./config.js";
|
|
19
|
+
/** Resolve the token-store path for a given environment (beside config). */
|
|
20
|
+
export function resolveMcpTokenPath(env = process.env) {
|
|
21
|
+
const configPath = resolveMcpConfigPath(env);
|
|
22
|
+
return join(dirname(configPath), "mcp-tokens.json");
|
|
23
|
+
}
|
|
24
|
+
function isRecord(value) {
|
|
25
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
26
|
+
}
|
|
27
|
+
function parseTokenEntry(name, value, source) {
|
|
28
|
+
if (!isRecord(value)) {
|
|
29
|
+
throw new Error(`token store ${source}: entry "${name}" must be an object`);
|
|
30
|
+
}
|
|
31
|
+
if (typeof value.access_token !== "string" ||
|
|
32
|
+
typeof value.token_type !== "string" ||
|
|
33
|
+
typeof value.obtained_at !== "number") {
|
|
34
|
+
throw new Error(`token store ${source}: entry "${name}" needs string "access_token", string "token_type" and number "obtained_at"`);
|
|
35
|
+
}
|
|
36
|
+
if (value.refresh_token !== undefined &&
|
|
37
|
+
typeof value.refresh_token !== "string") {
|
|
38
|
+
throw new Error(`token store ${source}: entry "${name}" refresh_token must be a string`);
|
|
39
|
+
}
|
|
40
|
+
if (value.expires_at !== undefined && typeof value.expires_at !== "number") {
|
|
41
|
+
throw new Error(`token store ${source}: entry "${name}" expires_at must be a number`);
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
access_token: value.access_token,
|
|
45
|
+
token_type: value.token_type,
|
|
46
|
+
...(typeof value.refresh_token === "string"
|
|
47
|
+
? { refresh_token: value.refresh_token }
|
|
48
|
+
: {}),
|
|
49
|
+
...(typeof value.expires_at === "number"
|
|
50
|
+
? { expires_at: value.expires_at }
|
|
51
|
+
: {}),
|
|
52
|
+
obtained_at: value.obtained_at,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/** Parse and validate a token-store body. */
|
|
56
|
+
export function parseMcpTokenStore(text, source) {
|
|
57
|
+
let parsed;
|
|
58
|
+
try {
|
|
59
|
+
parsed = JSON.parse(text);
|
|
60
|
+
}
|
|
61
|
+
catch (cause) {
|
|
62
|
+
throw new Error(`token store ${source} is not valid JSON: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
63
|
+
}
|
|
64
|
+
if (!isRecord(parsed)) {
|
|
65
|
+
throw new Error(`token store ${source} must be a JSON object`);
|
|
66
|
+
}
|
|
67
|
+
const store = {};
|
|
68
|
+
for (const [name, value] of Object.entries(parsed)) {
|
|
69
|
+
store[name] = parseTokenEntry(name, value, source);
|
|
70
|
+
}
|
|
71
|
+
return store;
|
|
72
|
+
}
|
|
73
|
+
/** Load the token store from beside the resolved config (missing = empty). */
|
|
74
|
+
export function loadMcpTokens(env = process.env) {
|
|
75
|
+
const path = resolveMcpTokenPath(env);
|
|
76
|
+
if (!existsSync(path))
|
|
77
|
+
return {};
|
|
78
|
+
return parseMcpTokenStore(readFileSync(path, "utf8"), path);
|
|
79
|
+
}
|
|
80
|
+
/** Persist the store: 0600, beside the resolved config (create-on-write —
|
|
81
|
+
* the config dir is expected to exist since the config file lives there). */
|
|
82
|
+
export function saveMcpTokens(store, env = process.env) {
|
|
83
|
+
const path = resolveMcpTokenPath(env);
|
|
84
|
+
writeFileSync(path, JSON.stringify(store, null, "\t") + "\n", {
|
|
85
|
+
mode: 0o600,
|
|
86
|
+
});
|
|
87
|
+
// POSIX honors `mode` only on create; chmod so an existing file also ends
|
|
88
|
+
// 0600 (e.g. a rewritten store on a stricter umask).
|
|
89
|
+
chmodSync(path, 0o600);
|
|
90
|
+
}
|
|
91
|
+
/** Store one server's token (merge over the existing store). */
|
|
92
|
+
export function storeMcpToken(name, token, env = process.env) {
|
|
93
|
+
const store = loadMcpTokens(env);
|
|
94
|
+
store[name] = token;
|
|
95
|
+
saveMcpTokens(store, env);
|
|
96
|
+
return store;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The env var a stored token is injected as (ruling 3): stdio MCP servers
|
|
100
|
+
* take credentials through env, not HTTP headers. A server with both an
|
|
101
|
+
* oauth block and a stored token spawns with this set (merged OVER the
|
|
102
|
+
* server's own `env`, so config wins over inheritance).
|
|
103
|
+
*/
|
|
104
|
+
export const MCP_ACCESS_TOKEN_ENV = "MCP_ACCESS_TOKEN";
|
|
105
|
+
/**
|
|
106
|
+
* The spawn env for one server: the server's config env plus the stored
|
|
107
|
+
* token (as {@link MCP_ACCESS_TOKEN_ENV}) when the server has an oauth
|
|
108
|
+
* block AND a stored token. No oauth block, or no token yet (the user has
|
|
109
|
+
* not run `sigrun-ext-mcp oauth <name>`) → the server's env unchanged.
|
|
110
|
+
*/
|
|
111
|
+
export function spawnEnvWithToken(name, server, env = process.env) {
|
|
112
|
+
if (server.oauth === undefined)
|
|
113
|
+
return { ...server.env };
|
|
114
|
+
const token = loadMcpTokens(env)[name];
|
|
115
|
+
if (token === undefined)
|
|
116
|
+
return { ...server.env };
|
|
117
|
+
return { ...server.env, [MCP_ACCESS_TOKEN_ENV]: token.access_token };
|
|
118
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Wire types for the MCP stdio protocol surface this extension uses. */
|
|
2
|
+
import type { McpServerConfig } from "./config.js";
|
|
3
|
+
/** One tool as `tools/list` reports it. */
|
|
4
|
+
export interface McpToolDefinition {
|
|
5
|
+
readonly name: string;
|
|
6
|
+
readonly description?: string;
|
|
7
|
+
/** JSON Schema for the tool's arguments object. */
|
|
8
|
+
readonly inputSchema?: {
|
|
9
|
+
readonly type?: unknown;
|
|
10
|
+
readonly properties?: unknown;
|
|
11
|
+
readonly required?: unknown;
|
|
12
|
+
[key: string]: unknown;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/** One content block in a `tools/call` result. */
|
|
16
|
+
export interface McpContentBlock {
|
|
17
|
+
readonly type: string;
|
|
18
|
+
readonly text?: string;
|
|
19
|
+
[key: string]: unknown;
|
|
20
|
+
}
|
|
21
|
+
/** A `tools/call` result: content blocks plus the error flag. */
|
|
22
|
+
export interface McpToolResult {
|
|
23
|
+
readonly content?: readonly McpContentBlock[];
|
|
24
|
+
/** True when the tool ran and reported failure (a valid outcome). */
|
|
25
|
+
readonly isError?: boolean;
|
|
26
|
+
[key: string]: unknown;
|
|
27
|
+
}
|
|
28
|
+
export type { McpServerConfig };
|
package/dist/types.js
ADDED