@typeship-ax/mcp 0.21.0 → 0.22.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/AGENTS.md +15 -11
- package/README.md +22 -53
- package/api.json +9998 -10118
- package/api.md +8983 -9120
- package/dist/arguments.d.ts +47 -0
- package/dist/arguments.d.ts.map +1 -0
- package/dist/arguments.js +254 -0
- package/dist/core/http.d.ts +162 -19
- package/dist/core/http.d.ts.map +1 -1
- package/dist/core/http.js +381 -48
- package/dist/core/pagination.d.ts +42 -6
- package/dist/core/pagination.d.ts.map +1 -1
- package/dist/core/pagination.js +111 -17
- package/dist/credential-storage.d.ts +10 -3
- package/dist/credential-storage.d.ts.map +1 -1
- package/dist/credential-storage.js +15 -6
- package/dist/dates.d.ts +1 -1
- package/dist/dates.js +1 -1
- package/dist/errors.d.ts +20 -84
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +20 -108
- package/dist/fields.d.ts +29 -0
- package/dist/fields.d.ts.map +1 -0
- package/dist/fields.js +101 -0
- package/dist/index.d.ts +28 -18
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +35 -25
- package/dist/mcp-authorization.d.ts.map +1 -1
- package/dist/mcp-authorization.js +34 -10
- package/dist/mcp-protocol.d.ts +87 -44
- package/dist/mcp-protocol.d.ts.map +1 -1
- package/dist/mcp-protocol.js +552 -478
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +127 -28
- package/dist/named-credentials.d.ts +19 -0
- package/dist/named-credentials.d.ts.map +1 -1
- package/dist/named-credentials.js +81 -1
- package/dist/oauth-request.d.ts +7 -1
- package/dist/oauth-request.d.ts.map +1 -1
- package/dist/oauth-request.js +26 -4
- package/dist/oauth-session.d.ts +13 -1
- package/dist/oauth-session.d.ts.map +1 -1
- package/dist/oauth-session.js +34 -18
- package/dist/ops.d.ts +53 -5
- package/dist/ops.d.ts.map +1 -1
- package/dist/ops.js +49 -40
- package/dist/resources/api-keys.d.ts +10 -7
- package/dist/resources/api-keys.d.ts.map +1 -1
- package/dist/resources/api-keys.js +10 -31
- package/dist/resources/deliveries.d.ts +88 -4
- package/dist/resources/deliveries.d.ts.map +1 -1
- package/dist/resources/deliveries.js +95 -18
- package/dist/resources/drafts.d.ts +15 -15
- package/dist/resources/drafts.d.ts.map +1 -1
- package/dist/resources/drafts.js +11 -64
- package/dist/resources/files.d.ts +4 -4
- package/dist/resources/files.d.ts.map +1 -1
- package/dist/resources/files.js +3 -12
- package/dist/resources/generations.d.ts +14 -14
- package/dist/resources/generations.d.ts.map +1 -1
- package/dist/resources/generations.js +21 -45
- package/dist/resources/organization.d.ts +4 -4
- package/dist/resources/organization.d.ts.map +1 -1
- package/dist/resources/organization.js +3 -10
- package/dist/resources/{generate.d.ts → packages.d.ts} +16 -16
- package/dist/resources/packages.d.ts.map +1 -0
- package/dist/resources/{generate.js → packages.js} +13 -29
- package/dist/resources/projects.d.ts +50 -50
- package/dist/resources/projects.d.ts.map +1 -1
- package/dist/resources/projects.js +60 -116
- package/dist/resources/releases.d.ts +21 -16
- package/dist/resources/releases.d.ts.map +1 -1
- package/dist/resources/releases.js +18 -39
- package/dist/resources/spec-revisions.d.ts +15 -6
- package/dist/resources/spec-revisions.d.ts.map +1 -1
- package/dist/resources/spec-revisions.js +6 -28
- package/dist/resources/specs.d.ts +7 -7
- package/dist/resources/specs.d.ts.map +1 -1
- package/dist/resources/specs.js +6 -34
- package/dist/resources/targets.d.ts +48 -48
- package/dist/resources/targets.d.ts.map +1 -1
- package/dist/resources/targets.js +58 -114
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +78 -76
- package/dist/search.d.ts +54 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +421 -0
- package/dist/types.d.ts +499 -339
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +18 -18
- package/dist/worker.js +2 -2
- package/package.json +5 -2
- package/server.json +5 -5
- package/src/arguments.ts +242 -0
- package/src/core/http.ts +457 -58
- package/src/core/pagination.ts +129 -18
- package/src/credential-storage.ts +16 -6
- package/src/dates.ts +1 -1
- package/src/errors.ts +46 -115
- package/src/fields.ts +91 -0
- package/src/index.ts +45 -28
- package/src/mcp-authorization.ts +29 -9
- package/src/mcp-protocol.ts +580 -428
- package/src/mcp.ts +113 -26
- package/src/named-credentials.ts +66 -1
- package/src/oauth-request.ts +32 -6
- package/src/oauth-session.ts +37 -19
- package/src/ops.ts +82 -44
- package/src/resources/api-keys.ts +34 -48
- package/src/resources/deliveries.ts +211 -30
- package/src/resources/drafts.ts +60 -107
- package/src/resources/files.ts +19 -20
- package/src/resources/generations.ts +57 -75
- package/src/resources/organization.ts +11 -16
- package/src/resources/{generate.ts → packages.ts} +43 -51
- package/src/resources/projects.ts +145 -200
- package/src/resources/releases.ts +48 -65
- package/src/resources/spec-revisions.ts +38 -47
- package/src/resources/specs.ts +39 -59
- package/src/resources/targets.ts +143 -193
- package/src/schemas.ts +78 -76
- package/src/search.ts +434 -0
- package/src/types.ts +538 -357
- package/src/worker.ts +2 -2
- package/dist/resources/generate.d.ts.map +0 -1
- package/dist/resources/publications.d.ts +0 -47
- package/dist/resources/publications.d.ts.map +0 -1
- package/dist/resources/publications.js +0 -70
- package/src/resources/publications.ts +0 -140
package/src/mcp.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
//
|
|
2
|
+
// Typeship — MCP server. Generated by Typeship — https://typeship.dev
|
|
3
3
|
// Speaks MCP 2026-07-28 and accepts the 2025-11-25 initialize handshake.
|
|
4
4
|
// HTTP is stateless (no sessions); the protocol layer and tool surface
|
|
5
|
-
// live in ./mcp-protocol.js, shared
|
|
6
|
-
//
|
|
5
|
+
// live in ./mcp-protocol.js, shared by both transports. Two transports,
|
|
6
|
+
// zero dependencies:
|
|
7
7
|
// node mcp.js stdio (newline-delimited JSON-RPC 2.0)
|
|
8
8
|
// createMcpHandler() configured Streamable HTTP handler
|
|
9
9
|
// Surface switches (flags or environment):
|
|
@@ -22,19 +22,19 @@ import { homedir, tmpdir } from "node:os";
|
|
|
22
22
|
import { basename, join } from "node:path";
|
|
23
23
|
import { fileURLToPath } from "node:url";
|
|
24
24
|
import { TypeshipClient, formatDebugEvent, type ClientOptions, type DebugEvent } from "./index.js";
|
|
25
|
-
import { asApiResult } from "./core/http.js";
|
|
25
|
+
import { asApiResult, mediaTypeForPath } from "./core/http.js";
|
|
26
26
|
import { GLOBALS, OMITTED_OPS, OPS, buildArgs, type OpSpec } from "./ops.js";
|
|
27
27
|
import {
|
|
28
28
|
DEFAULT_MAX_RESULT_CHARS, SUPPORTED_PROTOCOL_VERSIONS, McpAccountLinkRequired, argumentsError, asJsonRpc, binaryOutcome, callSharedTool, checkRequestHeaders,
|
|
29
|
-
createStdioRpcHandler, dataOutcome, errorOutcome, handleRpc, isRpcOutcome, pageOutcome, parseIncludeList, prepareCall, resolveReferences, serverInstructions,
|
|
30
|
-
takeCancelled, textError, toolDefinitions, visibleOps,
|
|
29
|
+
createStdioRpcHandler, dataOutcome, errorOutcome, handleRpc, hiddenOperations, isRpcOutcome, pageOutcome, parseIncludeList, prepareCall, resolveReferences, serverInstructions,
|
|
30
|
+
requiredScopes, takeCancelled, textError, toolDefinitions, unmatchedIncludes, unsupportedAuthOutcome, visibleOps,
|
|
31
31
|
type ArgumentIssue, type DocsSource, type McpServer, type OpLike, type RpcOutcome, type ToolOutcome,
|
|
32
32
|
} from "./mcp-protocol.js";
|
|
33
33
|
import { fetchDocsText } from "./docs.js";
|
|
34
|
-
import { assertCredentialDestination, assertStoredIdentity, oauthSessionToken } from "./oauth-session.js";
|
|
34
|
+
import { assertCredentialDestination, assertStoredIdentity, oauthSessionToken, sessionCredential } from "./oauth-session.js";
|
|
35
35
|
import { createCredentialStore } from "./credential-storage.js";
|
|
36
36
|
import { identityPolicyOf, verifyClientIdentity, type IdentityConfiguration } from "./api-identity.js";
|
|
37
|
-
import { parseNamedCredentials, resolveNamedCredentials, namedCredentialAvailability, type NamedCredentials, type CredentialSchemes } from "./named-credentials.js";
|
|
37
|
+
import { parseNamedCredentials, resolveNamedCredentials, namedCredentialAvailability, missingCredentials, oauthSessionSchemes, parseExtraHeaders, applyExtraHeaders, type NamedCredentials, type CredentialSchemes } from "./named-credentials.js";
|
|
38
38
|
import { resolveProfile, profileFlag, readProfileConfig } from "./auth-profiles.js";
|
|
39
39
|
import { createMcpAuthorizer, McpAuthorizationError, type McpAuthorizationConfiguration, type McpTokenIntrospectionConfiguration, type McpPrincipal } from "./mcp-authorization.js";
|
|
40
40
|
export type { McpAuthorizationConfiguration, McpTokenIntrospectionConfiguration, McpPrincipal } from "./mcp-authorization.js";
|
|
@@ -43,7 +43,7 @@ export { McpAccountLinkRequired } from "./mcp-protocol.js";
|
|
|
43
43
|
const BIN = "typeship";
|
|
44
44
|
const PKG_NAME = "@typeship-ax/mcp";
|
|
45
45
|
const SERVER_NAME = "typeship-mcp";
|
|
46
|
-
const SERVER_VERSION = "0.
|
|
46
|
+
const SERVER_VERSION = "0.22.0";
|
|
47
47
|
/** The MCP client's announced name (clientInfo in request _meta), for the User-Agent. */
|
|
48
48
|
let MCP_CLIENT_NAME: string | null = null;
|
|
49
49
|
function noteClientInfo(message: unknown): void {
|
|
@@ -57,8 +57,8 @@ const AUTH_SCALARS: { option: string; flag: string; env: string }[] = [{"option"
|
|
|
57
57
|
const BASIC: { envUser: string; envPass: string } | null = null;
|
|
58
58
|
|
|
59
59
|
const ENVIRONMENTS: Record<string, string> = {};
|
|
60
|
-
const DOCS_URL_DEFAULT: string | null = "https://typeship.dev";
|
|
61
|
-
const DOCS_INDEX_URL_DEFAULT: string | null =
|
|
60
|
+
const DOCS_URL_DEFAULT: string | null = "https://typeship.dev/docs";
|
|
61
|
+
const DOCS_INDEX_URL_DEFAULT: string | null = "https://typeship.dev/llms.txt";
|
|
62
62
|
/** "meta" collapses per-operation tools into search/read/execute so huge
|
|
63
63
|
* APIs don't flood agent context with hundreds of tools. */
|
|
64
64
|
const TOOL_MODE: "operations" | "meta" = "meta";
|
|
@@ -71,6 +71,10 @@ const IDENTITY_TOOL: string | null = null;
|
|
|
71
71
|
const ARGV = process.argv.slice(2);
|
|
72
72
|
const READ_ONLY = ARGV.includes("--read-only") || process.env["TYPESHIP_MCP_READ_ONLY"] === "1" || process.env["TYPESHIP_MCP_READ_ONLY"] === "true";
|
|
73
73
|
const INCLUDE = parseIncludeList(ARGV.includes("--tools") ? ARGV[ARGV.indexOf("--tools") + 1] : process.env["TYPESHIP_MCP_TOOLS"]);
|
|
74
|
+
/** How the operator set each switch, for errors that name it. */
|
|
75
|
+
const READ_ONLY_SWITCH = ARGV.includes("--read-only") ? "--read-only" : "TYPESHIP_MCP_READ_ONLY";
|
|
76
|
+
const TOOLS_SWITCH = ARGV.includes("--tools") ? "--tools" : "TYPESHIP_MCP_TOOLS";
|
|
77
|
+
const HEADER_ARGS = ARGV.flatMap((arg, index) => arg === "--header" && ARGV[index + 1] !== undefined ? [ARGV[index + 1]!] : arg.startsWith("--header=") ? [arg.slice("--header=".length)] : []);
|
|
74
78
|
const MAX_RESULT_CHARS = Number(process.env["TYPESHIP_MCP_MAX_RESULT_CHARS"]) || DEFAULT_MAX_RESULT_CHARS;
|
|
75
79
|
/** One sentence on where credentials come from on each transport; goes
|
|
76
80
|
* into the instructions and into 401 results. */
|
|
@@ -97,7 +101,7 @@ function readJson<T>(file: string): T | null {
|
|
|
97
101
|
|
|
98
102
|
function makeClient(op: OpSpec): TypeshipClient {
|
|
99
103
|
const profile = LOCAL_CREDENTIALS ? resolveProfile(configRoot(), { flag: profileFlag(ARGV), environment: process.env["TYPESHIP_PROFILE"] }) : null;
|
|
100
|
-
const store = createCredentialStore(profile?.directory ?? configRoot(), process.env["TYPESHIP_CREDENTIAL_STORE"], "TYPESHIP_CREDENTIAL_STORE");
|
|
104
|
+
const store = createCredentialStore(profile?.directory ?? configRoot(), BIN, process.env["TYPESHIP_CREDENTIAL_STORE"], "TYPESHIP_CREDENTIAL_STORE");
|
|
101
105
|
if (BASIC && (process.env[BASIC.envUser] !== undefined) !== (process.env[BASIC.envPass] !== undefined)) throw new Error("Supply both Basic-auth environment variables together, or use a complete named Basic credential.");
|
|
102
106
|
const envNamed = LOCAL_CREDENTIALS && process.env["TYPESHIP_CREDENTIALS"] !== undefined ? parseNamedCredentials(process.env["TYPESHIP_CREDENTIALS"], NAMED_SCHEMES) : {};
|
|
103
107
|
const explicitOptions = new Set(AUTH_SCALARS.filter((a) => process.env[a.env] !== undefined).map((a) => a.option));
|
|
@@ -139,27 +143,71 @@ function makeClient(op: OpSpec): TypeshipClient {
|
|
|
139
143
|
]);
|
|
140
144
|
|
|
141
145
|
|
|
146
|
+
requireOperationCredentials(op, options);
|
|
142
147
|
for (const g of GLOBALS) {
|
|
143
148
|
const value = process.env["TYPESHIP_" + g.envSuffix];
|
|
144
149
|
if (value !== undefined) options[g.option] = value;
|
|
145
150
|
}
|
|
146
151
|
if (process.env["TYPESHIP_DEBUG"] === "1") {
|
|
147
|
-
options.debug = (event: DebugEvent) => process.stderr.write(formatDebugEvent(
|
|
152
|
+
options.debug = (event: DebugEvent) => process.stderr.write(formatDebugEvent(SERVER_NAME, event) + "\n");
|
|
148
153
|
}
|
|
149
154
|
// The local MCP server identifies itself (surface + the client it serves, when announced).
|
|
150
|
-
options.defaultHeaders = { "User-Agent":
|
|
155
|
+
options.defaultHeaders = { "User-Agent": SERVER_NAME + "/" + SERVER_VERSION + (MCP_CLIENT_NAME ? " (client=" + MCP_CLIENT_NAME + ")" : "") };
|
|
156
|
+
// --header / TYPESHIP_HEADERS: headers the spec does not declare, applied last.
|
|
157
|
+
const extraHeaders = LOCAL_CREDENTIALS ? parseExtraHeaders(process.env["TYPESHIP_HEADERS"], HEADER_ARGS, "TYPESHIP_HEADERS") : {};
|
|
158
|
+
if (Object.keys(extraHeaders).length) options.onRequest = (context) => { applyExtraHeaders(context.headers, extraHeaders); };
|
|
151
159
|
// Keep the SDK's machine-token cache while re-evaluating local configuration.
|
|
152
160
|
const key = createHash("sha256").update(JSON.stringify([options, config, profile?.name, stored?.oauth?.sessionId, null, process.env["TYPESHIP_DEBUG"]])).digest("hex");
|
|
153
161
|
if (clientInstance && clientKey === key) return clientInstance;
|
|
154
162
|
const client = new TypeshipClient(options);
|
|
163
|
+
CLIENT_BASIC.set(client, { basicAuth: options.basicAuth, credentials: options.credentials });
|
|
155
164
|
clientKey = key;
|
|
156
165
|
CLIENT_CREDENTIALS.set(client, Object.keys(options.credentials ?? {}).length > 0 || AUTH_SCALARS.some((a) => options[a.option] !== undefined) || options.basicAuth !== undefined || options.bearerToken !== undefined || options.clientCredentials !== undefined);
|
|
157
166
|
return (clientInstance = client);
|
|
158
167
|
}
|
|
159
168
|
|
|
169
|
+
/** A required operation without one complete credential alternative stops
|
|
170
|
+
* before any request, as the CLI does, with the variables that supply it.
|
|
171
|
+
* Operations with no supported alternative are reported separately. */
|
|
172
|
+
class MissingCredentialsError extends Error {
|
|
173
|
+
constructor(readonly outcome: ToolOutcome) { super("Missing credentials"); }
|
|
174
|
+
}
|
|
175
|
+
function requireOperationCredentials(op: OpSpec, options: ClientOptions & Record<string, unknown>): void {
|
|
176
|
+
if (op.auth !== "required") return;
|
|
177
|
+
const gap = missingCredentials(NAMED_SCHEMES, op.credentialOptions, options);
|
|
178
|
+
if (!gap || !gap.alternatives.length) return;
|
|
179
|
+
const names = new Set(gap.missing.flat());
|
|
180
|
+
const needs = (option: string) => [...names].some((name) => NAMED_SCHEMES[name]?.options.includes(option));
|
|
181
|
+
const scalars = AUTH_SCALARS.filter((scalar) => needs(scalar.option));
|
|
182
|
+
throw new MissingCredentialsError(textError(
|
|
183
|
+
op.tool + " needs one complete credential alternative. Missing security schemes: " + gap.missing.map((alternative) => alternative.join(" + ")).join(" OR ") + ". No request was sent.",
|
|
184
|
+
"NO_AUTH",
|
|
185
|
+
[
|
|
186
|
+
...scalars.map((scalar) => "Set " + scalar.env + " in the MCP server's environment."),
|
|
187
|
+
...(BASIC && needs("basicAuth") ? ["Set " + BASIC.envUser + " and " + BASIC.envPass + " in the MCP server's environment."] : []),
|
|
188
|
+
"Or set TYPESHIP_CREDENTIALS to a JSON object with every scheme in one alternative: " + gap.alternatives.map((alternative) => alternative.join(" + ")).join(" OR ") + ".",
|
|
189
|
+
"Credentials saved by '" + BIN + " login' are read on the next call without a restart.",
|
|
190
|
+
],
|
|
191
|
+
));
|
|
192
|
+
}
|
|
193
|
+
|
|
160
194
|
let clientInstance: TypeshipClient | undefined;
|
|
161
195
|
let clientKey = "";
|
|
162
196
|
const CLIENT_CREDENTIALS = new WeakMap<TypeshipClient, boolean>();
|
|
197
|
+
/** Each client's Basic credentials, for path arguments that default to the username. */
|
|
198
|
+
const CLIENT_BASIC = new WeakMap<TypeshipClient, { basicAuth?: unknown; credentials?: unknown }>();
|
|
199
|
+
|
|
200
|
+
/** The Basic-auth username a path argument of op defaults to (Twilio's
|
|
201
|
+
* AccountSid): the named scheme's, else basicAuth's. */
|
|
202
|
+
function credentialUsername(op: OpSpec, client: TypeshipClient): string | undefined {
|
|
203
|
+
const scheme = op.params.find((p) => p.credential)?.credential?.scheme;
|
|
204
|
+
const basic = scheme ? CLIENT_BASIC.get(client) : undefined;
|
|
205
|
+
if (!basic) return undefined;
|
|
206
|
+
const named = (basic.credentials as Record<string, unknown> | undefined)?.[scheme!];
|
|
207
|
+
const username = named && typeof named === "object" ? (named as { username?: unknown }).username
|
|
208
|
+
: (basic.basicAuth as { username?: unknown } | undefined)?.username;
|
|
209
|
+
return typeof username === "string" && username !== "" ? username : undefined;
|
|
210
|
+
}
|
|
163
211
|
|
|
164
212
|
/** Resolve configuration and credentials for each tool call, including login/logout changes. */
|
|
165
213
|
function getClient(op: OpSpec): TypeshipClient {
|
|
@@ -172,9 +220,9 @@ async function callOperationRaw(op: OpSpec, rawArgs: Record<string, unknown>, re
|
|
|
172
220
|
// Arguments are checked against the tool's schema first: unknown names,
|
|
173
221
|
// wrong types and missing requirements come back as one isError result,
|
|
174
222
|
// nothing reaches the API half-formed and nothing is dropped silently.
|
|
175
|
-
const prepared = prepareCall(op as unknown as OpLike, rawArgs, { maxChars: MAX_RESULT_CHARS });
|
|
223
|
+
const prepared = prepareCall(op as unknown as OpLike, rawArgs, { maxChars: MAX_RESULT_CHARS, credentialUsername: credentialUsername(op, client) });
|
|
176
224
|
if (!prepared.ok) return prepared.outcome;
|
|
177
|
-
const { args, fields, maxChars } = prepared.call;
|
|
225
|
+
const { args, fields, optionalFields, maxChars } = prepared.call;
|
|
178
226
|
const values: Record<string, unknown> = {};
|
|
179
227
|
for (const p of op.params) {
|
|
180
228
|
const argument = p.kind === "header" && p.name === "If-Match" ? "if_match" : p.name;
|
|
@@ -186,14 +234,24 @@ async function callOperationRaw(op: OpSpec, rawArgs: Record<string, unknown>, re
|
|
|
186
234
|
let rawBody: unknown = op.bodyStyle === "data" ? args.body : undefined;
|
|
187
235
|
if (LOCAL_PROCESS && !remote) {
|
|
188
236
|
for (const p of op.params) {
|
|
189
|
-
if (p.type !== "file"
|
|
190
|
-
|
|
237
|
+
if (p.type !== "file") continue;
|
|
238
|
+
const paths = values[p.name];
|
|
239
|
+
if (typeof paths !== "string" && !(Array.isArray(paths) && paths.every((path) => typeof path === "string"))) continue;
|
|
240
|
+
const files: File[] = [];
|
|
241
|
+
for (const path of Array.isArray(paths) ? paths as string[] : [paths]) {
|
|
242
|
+
try { files.push(fileArgument(path)); } catch (e) { fileIssues.push({ code: "INVALID_ARGUMENT", argument: p.name, message: p.name + ": cannot read " + path + " (" + (e as Error).message + ")" }); }
|
|
243
|
+
}
|
|
244
|
+
values[p.name] = Array.isArray(paths) ? files : files[0];
|
|
191
245
|
}
|
|
192
246
|
if (op.bodyKind === "binary" && typeof rawBody === "string") {
|
|
193
247
|
try { rawBody = fileArgument(rawBody); } catch (e) { fileIssues.push({ code: "INVALID_ARGUMENT", argument: "body", message: "body: cannot read " + String(rawBody) + " (" + (e as Error).message + ")" }); }
|
|
194
248
|
}
|
|
195
249
|
}
|
|
196
250
|
if (fileIssues.length > 0) return argumentsError(op, fileIssues);
|
|
251
|
+
// A tool result is one value: a streamed response is not available here.
|
|
252
|
+
if (op.streamMethod && args[op.streamMethod.flag] === op.streamMethod.value) {
|
|
253
|
+
return argumentsError(op, [{ code: "INVALID_ARGUMENT", argument: op.streamMethod.flag, message: op.streamMethod.flag + ": streaming is not available over MCP, which returns one result per call. Call again without " + op.streamMethod.flag + " " + JSON.stringify(op.streamMethod.value) + " to get the complete response." }]);
|
|
254
|
+
}
|
|
197
255
|
const callArgs = buildArgs(
|
|
198
256
|
op,
|
|
199
257
|
values,
|
|
@@ -204,8 +262,9 @@ async function callOperationRaw(op: OpSpec, rawArgs: Record<string, unknown>, re
|
|
|
204
262
|
authHint: remote ? AUTH_HINT_HTTP : AUTH_HINT_STDIO,
|
|
205
263
|
docsUrl: docsSource.docsUrl(),
|
|
206
264
|
hadCredential: !!remote || CLIENT_CREDENTIALS.get(client) === true,
|
|
265
|
+
requiredScopes: requiredScopes(op.security),
|
|
207
266
|
};
|
|
208
|
-
const shape = { fields, maxChars, pagination: op.pagination, args };
|
|
267
|
+
const shape = { fields, optionalFields, maxChars, pagination: op.pagination, args, safety: op.safety };
|
|
209
268
|
try {
|
|
210
269
|
const target = (client as unknown as Record<string, Record<string, (...a: unknown[]) => unknown>>)[op.resource]!;
|
|
211
270
|
let result = await asApiResult(target[op.method]!(...callArgs) as Promise<unknown>);
|
|
@@ -223,7 +282,7 @@ async function callOperationRaw(op: OpSpec, rawArgs: Record<string, unknown>, re
|
|
|
223
282
|
}
|
|
224
283
|
if (op.paginated) {
|
|
225
284
|
const page = result.data as { items: unknown[]; nextPageParams(): Record<string, unknown> | null; response: { requestId?: string } };
|
|
226
|
-
return pageOutcome(page.items, page.nextPageParams(), { ...shape, requestId: page.response.requestId });
|
|
285
|
+
return pageOutcome(page.items, page.nextPageParams(), { ...shape, requestId: page.response.requestId, carryArgs: op.params.filter((p) => p.required || p.credential).map((p) => p.name) });
|
|
227
286
|
}
|
|
228
287
|
// A binary body (the SDK hands back a Blob): an image block, or a file
|
|
229
288
|
// on disk, never "{}".
|
|
@@ -249,13 +308,23 @@ function referenceCacheFor(authHeader?: string): Map<string, string | number> {
|
|
|
249
308
|
}
|
|
250
309
|
|
|
251
310
|
async function callOperation(op: OpSpec, rawArgs: Record<string, unknown>, remote?: RemoteContext): Promise<ToolOutcome> {
|
|
252
|
-
|
|
311
|
+
// A path argument that defaults to the Basic-auth username needs the
|
|
312
|
+
// configured credential before validation; a credential failure is
|
|
313
|
+
// reported below, after the arguments.
|
|
314
|
+
let username: string | undefined;
|
|
315
|
+
if (op.params.some((p) => p.credential && rawArgs[p.name] === undefined)) {
|
|
316
|
+
try { username = credentialUsername(op, remote ? await remote.client() : getClient(op)); } catch { username = undefined; }
|
|
317
|
+
}
|
|
318
|
+
const prepared = prepareCall(op as unknown as OpLike, rawArgs, { maxChars: MAX_RESULT_CHARS, credentialUsername: username });
|
|
253
319
|
if (!prepared.ok) return prepared.outcome;
|
|
320
|
+
const unsupported = unsupportedAuthOutcome(op as unknown as OpLike);
|
|
321
|
+
if (unsupported) return unsupported;
|
|
254
322
|
// One client/session for the entire tool, including name-to-ID lookups.
|
|
255
323
|
// If login changes during a lookup, the token callback rejects the old session.
|
|
256
324
|
let client: TypeshipClient;
|
|
257
325
|
try { client = remote ? await remote.client() : getClient(op); } catch (error) {
|
|
258
326
|
if (remote && error instanceof McpAccountLinkRequired) throw error;
|
|
327
|
+
if (!remote && error instanceof MissingCredentialsError) return error.outcome;
|
|
259
328
|
return textError(remote ? "API access is unavailable for this MCP account. Reconnect your API account or contact the server owner." : (error as Error).message);
|
|
260
329
|
}
|
|
261
330
|
const resolved = await resolveReferences(op as unknown as OpLike, prepared.call.args, {
|
|
@@ -267,7 +336,7 @@ async function callOperation(op: OpSpec, rawArgs: Record<string, unknown>, remot
|
|
|
267
336
|
if (!resolved.ok) return resolved.outcome;
|
|
268
337
|
const args = {
|
|
269
338
|
...resolved.args,
|
|
270
|
-
...(prepared.call.fields ? { fields: prepared.call.fields.map((path) => path.join(".")) } : {}),
|
|
339
|
+
...(prepared.call.fields ? { fields: prepared.call.fields.map((path) => (prepared.call.optionalFields.includes(path.join(".")) ? "?" : "") + path.join(".")) } : {}),
|
|
271
340
|
};
|
|
272
341
|
return callOperationRaw(op, args, remote, client);
|
|
273
342
|
}
|
|
@@ -293,7 +362,7 @@ const HAS_UPLOADS = MCP_OPS.some((op) => op.bodyKind === "multipart" || op.bodyK
|
|
|
293
362
|
|
|
294
363
|
/** A local file as an upload part (multipart field or raw body). */
|
|
295
364
|
function fileArgument(path: string): File {
|
|
296
|
-
return new File([readFileSync(path)], basename(path));
|
|
365
|
+
return new File([readFileSync(path)], basename(path), { type: mediaTypeForPath(path) });
|
|
297
366
|
}
|
|
298
367
|
|
|
299
368
|
/** Where binary responses land on a local server: a per-server temp dir. */
|
|
@@ -308,6 +377,7 @@ function saveBinary(bytes: Uint8Array, _mediaType: string, suggestedName: string
|
|
|
308
377
|
const docsSource: DocsSource = {
|
|
309
378
|
ops: MCP_OPS as unknown as OpLike[],
|
|
310
379
|
omittedOps: OMITTED_OPS as unknown as OpLike[],
|
|
380
|
+
hiddenOps: hiddenOperations(OPS as unknown as OpLike[], { readOnly: READ_ONLY, include: INCLUDE, uploads: LOCAL_PROCESS }, { readOnly: READ_ONLY_SWITCH, include: TOOLS_SWITCH }),
|
|
311
381
|
generatedOperationCount: OPS.length,
|
|
312
382
|
docsUrl: () => readJson<{ docsUrl?: string }>("config.json")?.docsUrl ?? DOCS_URL_DEFAULT,
|
|
313
383
|
docsIndexUrl: () => readJson<{ docsUrl?: string }>("config.json")?.docsUrl ? null : DOCS_INDEX_URL_DEFAULT,
|
|
@@ -322,9 +392,9 @@ function serverFor(remote?: RemoteContext): McpServer {
|
|
|
322
392
|
const source = remote ? { ...docsSource, ops: operations } : docsSource;
|
|
323
393
|
const website = docsSource.docsUrl();
|
|
324
394
|
return {
|
|
325
|
-
serverInfo: { name: SERVER_NAME, title: "
|
|
395
|
+
serverInfo: { name: SERVER_NAME, title: "Typeship", version: SERVER_VERSION, ...(website ? { websiteUrl: website } : {}) },
|
|
326
396
|
instructions: serverInstructions({
|
|
327
|
-
title: "
|
|
397
|
+
title: "Typeship",
|
|
328
398
|
ops: operations,
|
|
329
399
|
toolCount: operations.length,
|
|
330
400
|
generatedOperationCount: OPS.length,
|
|
@@ -452,12 +522,15 @@ async function authorizedHttp(request: Request, authorizer: ReturnType<typeof cr
|
|
|
452
522
|
if (credentials.baseUrl !== undefined && credentials.baseUrl !== baseUrl) throw new Error("API credentials target a different API URL.");
|
|
453
523
|
const token = request.headers.get("authorization")!.slice(7);
|
|
454
524
|
const transport = credentials.fetch ?? fetch;
|
|
455
|
-
|
|
525
|
+
const created = new TypeshipClient({ ...credentials, baseUrl, fetch: async (input, init) => {
|
|
456
526
|
const headers = new Headers(init?.headers);
|
|
457
527
|
const destination = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url);
|
|
458
528
|
if ([...headers.values()].some(value => value === token || value === "Bearer " + token) || [...destination.searchParams.values()].includes(token)) throw new Error("An MCP connection token cannot authorize an upstream API request.");
|
|
459
529
|
return transport(input, init);
|
|
460
530
|
} });
|
|
531
|
+
const { basicAuth, credentials: named } = credentials as unknown as { basicAuth?: unknown; credentials?: unknown };
|
|
532
|
+
CLIENT_BASIC.set(created, { basicAuth, credentials: named });
|
|
533
|
+
return created;
|
|
461
534
|
})() };
|
|
462
535
|
|
|
463
536
|
let incoming: unknown;
|
|
@@ -569,7 +642,21 @@ function invokedDirectly(): boolean {
|
|
|
569
642
|
return /mcp\.(js|mjs|ts)$/.test(entry);
|
|
570
643
|
}
|
|
571
644
|
}
|
|
645
|
+
/** A --tools value that names nothing would start a server with no
|
|
646
|
+
* operations, which reads to an agent like an empty API. */
|
|
647
|
+
function toolsSwitchProblem(): string | null {
|
|
648
|
+
if (ARGV.includes("--tools") && INCLUDE === undefined) return "--tools needs a comma-separated list of resources, tool names, or resource.method.";
|
|
649
|
+
const unmatched = unmatchedIncludes(OPS as unknown as OpLike[], INCLUDE);
|
|
650
|
+
if (unmatched.length === 0) return null;
|
|
651
|
+
const resources = [...new Set(OPS.map((op) => op.resource))];
|
|
652
|
+
return TOOLS_SWITCH + " names nothing in this API: " + unmatched.join(", ") + ". Resources: " + resources.slice(0, 30).join(", ") + (resources.length > 30 ? ", …" : "") + ".";
|
|
653
|
+
}
|
|
572
654
|
if (invokedDirectly()) {
|
|
655
|
+
const problem = toolsSwitchProblem();
|
|
656
|
+
if (problem !== null) {
|
|
657
|
+
process.stderr.write(SERVER_NAME + ": " + problem + "\n");
|
|
658
|
+
process.exit(2);
|
|
659
|
+
}
|
|
573
660
|
const httpFlag = process.argv.indexOf("--http");
|
|
574
661
|
if (httpFlag !== -1) {
|
|
575
662
|
const port = Number(process.argv[httpFlag + 1]) || Number(process.env.PORT) || 3000;
|
package/src/named-credentials.ts
CHANGED
|
@@ -34,7 +34,7 @@ export function resolveNamedCredentials(schemes: CredentialSchemes, layers: { na
|
|
|
34
34
|
for (const layer of layers) {
|
|
35
35
|
for (const [name, scheme] of Object.entries(schemes)) {
|
|
36
36
|
for (const option of scheme.options) {
|
|
37
|
-
if (option === "clientCredentials") continue;
|
|
37
|
+
if (option === "clientCredentials" || option === "refreshToken") continue;
|
|
38
38
|
const value = layer.options?.[option];
|
|
39
39
|
if (value !== undefined) resolved[name] = value as NamedCredential;
|
|
40
40
|
}
|
|
@@ -52,6 +52,28 @@ export function namedCredentialAvailability(schemes: CredentialSchemes, options:
|
|
|
52
52
|
return options;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/** The schemes an interactive OAuth login session authenticates by name. When
|
|
56
|
+
* the API also declares a separate non-OAuth bearer scheme, the convenience
|
|
57
|
+
* bearer token belongs to that scheme, so the session must be passed to each
|
|
58
|
+
* OAuth scheme by name. Empty: the session is the convenience bearer token. */
|
|
59
|
+
export function oauthSessionSchemes(schemes: CredentialSchemes): string[] {
|
|
60
|
+
return Object.entries(schemes).filter(([, scheme]) => (scheme.options.includes("clientCredentials") || scheme.options.includes("refreshToken")) && !scheme.options.includes("bearerToken")).map(([name]) => name);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Whether resolved client options satisfy one complete credential alternative
|
|
64
|
+
* of an operation. Returns null when satisfied (or when no credential is
|
|
65
|
+
* required); otherwise the named alternatives and the schemes each lacks. */
|
|
66
|
+
export function missingCredentials(schemes: CredentialSchemes, credentialOptions: string[][] | undefined, options: Record<string, unknown>): { alternatives: string[][]; missing: string[][] } | null {
|
|
67
|
+
const supplied = (key: string, value: unknown): boolean => typeof value === "function" || (typeof value === "string" && value.length > 0)
|
|
68
|
+
|| (key === "clientCredentials" && value !== null && typeof value === "object")
|
|
69
|
+
|| (value !== null && typeof value === "object" && "username" in value && "password" in value && typeof value.username === "string" && value.username.length > 0 && typeof value.password === "string" && value.password.length > 0);
|
|
70
|
+
const named = Object.fromEntries(Object.entries((options.credentials ?? {}) as Record<string, unknown>).filter(([name, value]) => supplied(name, value))) as NamedCredentials;
|
|
71
|
+
const available = namedCredentialAvailability(schemes, new Set(Object.keys(options).filter((key) => key !== "credentials" && supplied(key, options[key]))), named);
|
|
72
|
+
if (credentialOptions?.some((alternative) => alternative.length > 0 && alternative.every((option) => available.has(option)))) return null;
|
|
73
|
+
const alternatives = (credentialOptions ?? []).filter((alternative) => alternative.length > 0 && alternative.every((option) => option.startsWith("credentials."))).map((alternative) => alternative.map((option) => option.slice("credentials.".length)));
|
|
74
|
+
return { alternatives, missing: alternatives.map((alternative) => alternative.filter((name) => !available.has("credentials." + name))) };
|
|
75
|
+
}
|
|
76
|
+
|
|
55
77
|
/** Bound file/stdin reads before allocating a full credential document. */
|
|
56
78
|
export function readNamedCredentialsFile(input: string, schemes: CredentialSchemes): NamedCredentials {
|
|
57
79
|
let fd: number | undefined;
|
|
@@ -72,3 +94,46 @@ export function readNamedCredentialsFile(input: string, schemes: CredentialSchem
|
|
|
72
94
|
} finally { if (fd !== undefined && input !== "-") closeSync(fd); }
|
|
73
95
|
return parseNamedCredentials(Buffer.concat(chunks).toString("utf8"), schemes);
|
|
74
96
|
}
|
|
97
|
+
|
|
98
|
+
const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
|
99
|
+
const TRANSPORT_HEADERS = new Set(["host", "content-length", "content-type", "transfer-encoding", "connection"]);
|
|
100
|
+
|
|
101
|
+
/** Extra request headers from repeated `--header "Name: value"` flags and a
|
|
102
|
+
* `<PREFIX>_HEADERS` variable (a JSON object, or one `Name: value` per line).
|
|
103
|
+
* The escape hatch for credentials a spec does not declare. Flags win over
|
|
104
|
+
* the environment; values never appear in errors. */
|
|
105
|
+
export function parseExtraHeaders(environment: string | undefined, flags: readonly string[], variable: string): Record<string, string> {
|
|
106
|
+
const entries: [string, string][] = [];
|
|
107
|
+
const line = (text: string, source: string) => {
|
|
108
|
+
const colon = text.indexOf(":");
|
|
109
|
+
if (colon <= 0) throw new Error(source + " expects \"Name: value\".");
|
|
110
|
+
entries.push([text.slice(0, colon).trim(), text.slice(colon + 1).trim()]);
|
|
111
|
+
};
|
|
112
|
+
if (environment !== undefined && environment.trim()) {
|
|
113
|
+
const text = environment.trim();
|
|
114
|
+
if (text.startsWith("{")) {
|
|
115
|
+
let value: unknown;
|
|
116
|
+
try { value = JSON.parse(text); } catch { throw new Error(variable + " must be a JSON object of header names to values, or one \"Name: value\" per line."); }
|
|
117
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || Object.values(value).some((entry) => typeof entry !== "string")) throw new Error(variable + " must map header names to string values.");
|
|
118
|
+
entries.push(...Object.entries(value as Record<string, string>));
|
|
119
|
+
} else for (const part of text.split(/\r?\n/)) if (part.trim()) line(part, variable);
|
|
120
|
+
}
|
|
121
|
+
for (const flag of flags) line(flag, "--header");
|
|
122
|
+
const headers: Record<string, string> = {};
|
|
123
|
+
for (const [name, value] of entries) {
|
|
124
|
+
if (!HEADER_NAME.test(name)) throw new Error("Header names may contain only token characters. Check --header and " + variable + ".");
|
|
125
|
+
if (TRANSPORT_HEADERS.has(name.toLowerCase())) throw new Error("The " + name + " header is set by the client and cannot be overridden.");
|
|
126
|
+
if (/[\r\n\0]/.test(value)) throw new Error("Header values cannot contain line breaks. Check --header and " + variable + ".");
|
|
127
|
+
for (const existing of Object.keys(headers)) if (existing.toLowerCase() === name.toLowerCase()) delete headers[existing];
|
|
128
|
+
headers[name] = value;
|
|
129
|
+
}
|
|
130
|
+
return headers;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Apply extra headers last, replacing any header of the same name. */
|
|
134
|
+
export function applyExtraHeaders(target: Record<string, string>, extra: Record<string, string>): void {
|
|
135
|
+
for (const [name, value] of Object.entries(extra)) {
|
|
136
|
+
for (const existing of Object.keys(target)) if (existing.toLowerCase() === name.toLowerCase()) delete target[existing];
|
|
137
|
+
target[name] = value;
|
|
138
|
+
}
|
|
139
|
+
}
|
package/src/oauth-request.ts
CHANGED
|
@@ -16,9 +16,23 @@ export class OAuthResponseError extends Error {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
export type DeviceOAuthError = "authorization_pending" | "slow_down" | "access_denied" | "expired_token";
|
|
19
|
-
interface AuthenticationResponse {
|
|
19
|
+
interface AuthenticationResponse {
|
|
20
|
+
status: number; data?: Record<string, unknown>; error?: DeviceOAuthError;
|
|
21
|
+
/** RFC 6749 `error` and `error_description` from a 400 or 401 body, as
|
|
22
|
+
* "code: description". Only those two fields, printable ASCII, bounded. */
|
|
23
|
+
providerError?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The provider's standard error fields, never any other body content. */
|
|
27
|
+
export function providerErrorOf(data: Record<string, unknown>): string | undefined {
|
|
28
|
+
const code = typeof data.error === "string" && /^[\x20-\x21\x23-\x5b\x5d-\x7e]{1,64}$/.test(data.error) ? data.error : undefined;
|
|
29
|
+
if (!code) return undefined;
|
|
30
|
+
const description = typeof data.error_description === "string" ? data.error_description.replace(/[^\x20-\x7e]/g, " ").trim().slice(0, 300) : "";
|
|
31
|
+
return description ? code + ": " + description : code;
|
|
32
|
+
}
|
|
20
33
|
|
|
21
|
-
/**
|
|
34
|
+
/** Transport causes and error bodies beyond the standard OAuth `error` and
|
|
35
|
+
* `error_description` fields may contain credentials and are discarded. */
|
|
22
36
|
export function oauthJsonRequest(url: string | URL, init: RequestInit = {}, timeoutMs = 30_000): Promise<AuthenticationResponse> {
|
|
23
37
|
return boundedRequest(url, init, timeoutMs, "json");
|
|
24
38
|
}
|
|
@@ -39,6 +53,9 @@ async function boundedRequest(url: string | URL, init: RequestInit, timeoutMs: n
|
|
|
39
53
|
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
|
40
54
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
41
55
|
let interrupted: "timed_out" | "cancelled" | undefined;
|
|
56
|
+
// An error status is already an answer: its body only adds the provider's
|
|
57
|
+
// explanation, so a stalled or malformed error body never becomes a failure.
|
|
58
|
+
let errorStatus: number | undefined;
|
|
42
59
|
let rejectDeadline!: (reason: Error) => void;
|
|
43
60
|
const deadline = new Promise<never>((_, reject) => { rejectDeadline = reject; });
|
|
44
61
|
const stop = (code: "timed_out" | "cancelled") => { interrupted ??= code; rejectDeadline(new OAuthResponseError(interrupted)); controller.abort(); };
|
|
@@ -56,7 +73,8 @@ async function boundedRequest(url: string | URL, init: RequestInit, timeoutMs: n
|
|
|
56
73
|
}), deadline,
|
|
57
74
|
]);
|
|
58
75
|
if (response.redirected) { void response.body?.cancel().catch(() => {}); throw new OAuthResponseError("request_failed"); }
|
|
59
|
-
if (mode === "status" || response.status !== 200 &&
|
|
76
|
+
if (mode === "status" || response.status !== 200 && response.status !== 400 && response.status !== 401) { void response.body?.cancel().catch(() => {}); return { status: response.status }; }
|
|
77
|
+
if (response.status !== 200) errorStatus = response.status;
|
|
60
78
|
if (!response.body) throw new OAuthResponseError("invalid_response");
|
|
61
79
|
reader = response.body.getReader();
|
|
62
80
|
let size = 0, text = "";
|
|
@@ -72,14 +90,22 @@ async function boundedRequest(url: string | URL, init: RequestInit, timeoutMs: n
|
|
|
72
90
|
try { text += decoder.decode(); }
|
|
73
91
|
catch { throw new OAuthResponseError("invalid_response"); }
|
|
74
92
|
let data: unknown;
|
|
75
|
-
try { data = JSON.parse(text); } catch {
|
|
76
|
-
|
|
93
|
+
try { data = JSON.parse(text); } catch {
|
|
94
|
+
if (response.status !== 200) return { status: response.status };
|
|
95
|
+
throw new OAuthResponseError("invalid_response");
|
|
96
|
+
}
|
|
97
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
98
|
+
if (response.status !== 200) return { status: response.status };
|
|
99
|
+
throw new OAuthResponseError("invalid_response");
|
|
100
|
+
}
|
|
77
101
|
if (response.status !== 200) {
|
|
78
102
|
const error = (data as Record<string, unknown>).error;
|
|
79
|
-
|
|
103
|
+
const providerError = providerErrorOf(data as Record<string, unknown>);
|
|
104
|
+
return { status: response.status, ...(providerError ? { providerError } : {}), ...(mode === "device" && ["authorization_pending", "slow_down", "access_denied", "expired_token"].includes(String(error)) && typeof error === "string" ? { error: error as DeviceOAuthError } : {}) };
|
|
80
105
|
}
|
|
81
106
|
return { status: response.status, data: data as Record<string, unknown> };
|
|
82
107
|
} catch (error) {
|
|
108
|
+
if (errorStatus !== undefined && interrupted !== "cancelled") return { status: errorStatus };
|
|
83
109
|
if (interrupted) throw new OAuthResponseError(interrupted);
|
|
84
110
|
if (error instanceof OAuthResponseError) throw error;
|
|
85
111
|
throw new OAuthResponseError("request_failed");
|
package/src/oauth-session.ts
CHANGED
|
@@ -26,9 +26,9 @@ export interface CredentialDestination { apiBaseUrl: string; environment?: strin
|
|
|
26
26
|
|
|
27
27
|
export function assertCredentialDestination(credentials: StoredCredentials, destination: CredentialDestination): void {
|
|
28
28
|
const saved = credentials.destination;
|
|
29
|
-
if (!saved) throw new
|
|
30
|
-
if (new URL(saved.apiBaseUrl).href !== new URL(destination.apiBaseUrl).href) throw new
|
|
31
|
-
if ((saved.environment ?? null) !== (destination.environment ?? null) || (saved.profile ?? null) !== (destination.profile ?? null)) throw new
|
|
29
|
+
if (!saved) throw new OAuthSessionError("Saved credentials have no API and profile binding. Log in again before using them.");
|
|
30
|
+
if (new URL(saved.apiBaseUrl).href !== new URL(destination.apiBaseUrl).href) throw new OAuthSessionError("The API destination changed. Log in again before using saved credentials.");
|
|
31
|
+
if ((saved.environment ?? null) !== (destination.environment ?? null) || (saved.profile ?? null) !== (destination.profile ?? null)) throw new OAuthSessionError("The credential environment or profile changed. Log in again before using saved credentials.");
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
export interface StoredCredentials {
|
|
@@ -67,7 +67,7 @@ export function credentialIdentityBinding(credentials: StoredCredentials, identi
|
|
|
67
67
|
export function assertStoredIdentity(credentials: StoredCredentials, identity?: IdentityConfiguration): void {
|
|
68
68
|
if (!identity) return;
|
|
69
69
|
if (!credentials.identity?.values || credentials.identity.binding !== credentialIdentityBinding(credentials, identity) || Object.keys(identity.fields).some((kind) => !Object.hasOwn(credentials.identity!.values, kind))) {
|
|
70
|
-
throw new
|
|
70
|
+
throw new OAuthSessionError("The saved login has no matching API identity verification. Sign in again to verify the current account and organization.");
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
73
|
|
|
@@ -99,7 +99,14 @@ function alive(pid: number): boolean {
|
|
|
99
99
|
try { process.kill(pid, 0); return true; } catch (error) { return code(error) !== "ESRCH"; }
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
|
|
102
|
+
/** The OS credential store or the saved credential file cannot be used. */
|
|
103
|
+
export class CredentialStorageError extends Error {
|
|
104
|
+
constructor(message?: string) { super(message); this.name = "CredentialStorageError"; }
|
|
105
|
+
}
|
|
106
|
+
/** A saved login cannot be used as is; the remedy is to sign in again. */
|
|
107
|
+
export class OAuthSessionError extends Error {
|
|
108
|
+
constructor(message?: string) { super(message); this.name = "OAuthSessionError"; }
|
|
109
|
+
}
|
|
103
110
|
|
|
104
111
|
export interface CredentialCodec {
|
|
105
112
|
readonly name: string;
|
|
@@ -127,7 +134,7 @@ export class FileCredentialStore implements CredentialStore {
|
|
|
127
134
|
} catch (error) {
|
|
128
135
|
if (code(error) === "ENOENT") return null;
|
|
129
136
|
if (error instanceof CredentialStorageError) throw error;
|
|
130
|
-
throw new
|
|
137
|
+
throw new CredentialStorageError("Cannot read saved credentials. Check the credential store before logging in again.");
|
|
131
138
|
}
|
|
132
139
|
}
|
|
133
140
|
|
|
@@ -154,9 +161,9 @@ export class FileCredentialStore implements CredentialStore {
|
|
|
154
161
|
while (!acquired) {
|
|
155
162
|
try { linkSync(claim, lock); acquired = true; }
|
|
156
163
|
catch (error) {
|
|
157
|
-
if (code(error) !== "EEXIST") throw new
|
|
164
|
+
if (code(error) !== "EEXIST") throw new CredentialStorageError("Cannot lock the credential store.");
|
|
158
165
|
this.recoverDeadOwner(lock);
|
|
159
|
-
if (Date.now() >= deadline) throw new
|
|
166
|
+
if (Date.now() >= deadline) throw new CredentialStorageError("The credential store is busy. Wait for the other login or refresh to finish and retry. If its process stopped, inspect " + lock + ".");
|
|
160
167
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
161
168
|
}
|
|
162
169
|
}
|
|
@@ -199,13 +206,13 @@ export class FileCredentialStore implements CredentialStore {
|
|
|
199
206
|
|
|
200
207
|
function validSession(credentials: StoredCredentials | null, config: SessionConfiguration, sessionId: string, allowPending = false): StoredOAuthSession {
|
|
201
208
|
const session = credentials?.oauth;
|
|
202
|
-
if (!session || !session.sessionId || session.sessionId !== sessionId) throw new
|
|
203
|
-
if (session.apiBaseUrl && new URL(session.apiBaseUrl).href !== new URL(config.apiBaseUrl).href) throw new
|
|
204
|
-
if (session.issuer && config.issuer && session.issuer !== config.issuer) throw new
|
|
205
|
-
if (session.configuredClientId !== (config.clientId ?? null)) throw new
|
|
206
|
-
if (!session.binding || session.binding !== sessionBinding(config)) throw new
|
|
207
|
-
if (session.refreshPending && !allowPending) throw new
|
|
208
|
-
if (typeof session.accessToken !== "string" || !session.accessToken) throw new
|
|
209
|
+
if (!session || !session.sessionId || session.sessionId !== sessionId) throw new OAuthSessionError("The saved OAuth session changed or was logged out. Run login again and retry the request.");
|
|
210
|
+
if (session.apiBaseUrl && new URL(session.apiBaseUrl).href !== new URL(config.apiBaseUrl).href) throw new OAuthSessionError("The API destination changed. Log in again.");
|
|
211
|
+
if (session.issuer && config.issuer && session.issuer !== config.issuer) throw new OAuthSessionError("The configured issuer changed. Log in again.");
|
|
212
|
+
if (session.configuredClientId !== (config.clientId ?? null)) throw new OAuthSessionError("The OAuth client changed. Log in again.");
|
|
213
|
+
if (!session.binding || session.binding !== sessionBinding(config)) throw new OAuthSessionError("The API or OAuth configuration changed. Log in again before using saved credentials.");
|
|
214
|
+
if (session.refreshPending && !allowPending) throw new OAuthSessionError("The previous OAuth refresh did not finish safely. Log in again; its refresh token will not be reused.");
|
|
215
|
+
if (typeof session.accessToken !== "string" || !session.accessToken) throw new OAuthSessionError("The saved OAuth session is invalid. Log in again.");
|
|
209
216
|
assertStoredIdentity(credentials!, config.identity);
|
|
210
217
|
return session;
|
|
211
218
|
}
|
|
@@ -213,15 +220,17 @@ function validSession(credentials: StoredCredentials | null, config: SessionConf
|
|
|
213
220
|
/** Re-read on every request attempt; only one process may rotate the token.
|
|
214
221
|
* A failed or interrupted exchange requires login, since the server may have
|
|
215
222
|
* consumed its refresh token even when no response reached this process. */
|
|
216
|
-
export async function oauthSessionToken(store: CredentialStore, config: SessionConfiguration, sessionId: string, tokenParams: Record<string, string> = {}): Promise<string> {
|
|
217
|
-
|
|
223
|
+
export async function oauthSessionToken(store: CredentialStore, config: SessionConfiguration, sessionId: string, tokenParams: Record<string, string> = {}, rejected?: string): Promise<string> {
|
|
224
|
+
// A token the API just rejected (401) is refreshed once even if unexpired;
|
|
225
|
+
// another process may already have replaced it, which is fine.
|
|
226
|
+
const fresh = (session: StoredOAuthSession) => session.accessToken !== rejected && (session.expiresAt === undefined || (Number.isFinite(session.expiresAt) && session.expiresAt > Date.now() + 60_000));
|
|
218
227
|
const first = validSession(store.read(), config, sessionId, true);
|
|
219
228
|
if (!first.refreshPending && fresh(first)) return first.accessToken;
|
|
220
229
|
return store.withLock(async (locked) => {
|
|
221
230
|
const credentials = locked.read();
|
|
222
231
|
const session = validSession(credentials, config, sessionId);
|
|
223
232
|
if (fresh(session)) return session.accessToken;
|
|
224
|
-
if (!session.refreshToken || !session.tokenUrl || !session.clientId) throw new
|
|
233
|
+
if (!session.refreshToken || !session.tokenUrl || !session.clientId) throw new OAuthSessionError(session.accessToken === rejected ? "The API rejected the saved OAuth session and it has no refresh token. Log in again." : "The OAuth session expired and cannot be refreshed. Log in again.");
|
|
225
234
|
const endpoint = new URL(session.tokenUrl);
|
|
226
235
|
if ((endpoint.protocol !== "https:" && !(endpoint.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(endpoint.hostname))) || endpoint.username || endpoint.password || endpoint.hash) throw new Error("The OAuth token endpoint requires HTTPS; loopback HTTP is allowed for development.");
|
|
227
236
|
locked.write({ ...credentials, oauth: { ...session, refreshPending: true } });
|
|
@@ -252,7 +261,16 @@ export async function oauthSessionToken(store: CredentialStore, config: SessionC
|
|
|
252
261
|
return next.accessToken;
|
|
253
262
|
} catch {
|
|
254
263
|
// Never expose an error body, a refresh token, or fetch's nested cause.
|
|
255
|
-
throw new
|
|
264
|
+
throw new OAuthSessionError("The OAuth session could not be refreshed safely. Log in again.");
|
|
256
265
|
}
|
|
257
266
|
});
|
|
258
267
|
}
|
|
268
|
+
|
|
269
|
+
/** A saved-session token for the request runtime: resolved before every
|
|
270
|
+
* attempt, and after a 401 (invalidate) the next resolve forces one refresh
|
|
271
|
+
* of the token the API rejected. */
|
|
272
|
+
export function sessionCredential(resolve: (rejected?: string) => Promise<string>): (() => Promise<string>) & { invalidate(): void } {
|
|
273
|
+
let last: string | undefined, rejected: string | undefined;
|
|
274
|
+
const token = async () => { const previous = rejected; rejected = undefined; last = await resolve(previous); return last; };
|
|
275
|
+
return Object.assign(token, { invalidate() { rejected = last; } });
|
|
276
|
+
}
|