@typeship-ax/mcp 0.21.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/AGENTS.md +15 -11
  2. package/README.md +22 -53
  3. package/api.json +9998 -10118
  4. package/api.md +8983 -9120
  5. package/dist/arguments.d.ts +54 -0
  6. package/dist/arguments.d.ts.map +1 -0
  7. package/dist/arguments.js +265 -0
  8. package/dist/core/http.d.ts +162 -19
  9. package/dist/core/http.d.ts.map +1 -1
  10. package/dist/core/http.js +381 -48
  11. package/dist/core/pagination.d.ts +42 -6
  12. package/dist/core/pagination.d.ts.map +1 -1
  13. package/dist/core/pagination.js +111 -17
  14. package/dist/credential-storage.d.ts +10 -3
  15. package/dist/credential-storage.d.ts.map +1 -1
  16. package/dist/credential-storage.js +15 -6
  17. package/dist/dates.d.ts +1 -1
  18. package/dist/dates.js +1 -1
  19. package/dist/errors.d.ts +20 -84
  20. package/dist/errors.d.ts.map +1 -1
  21. package/dist/errors.js +20 -108
  22. package/dist/fields.d.ts +36 -0
  23. package/dist/fields.d.ts.map +1 -0
  24. package/dist/fields.js +187 -0
  25. package/dist/index.d.ts +28 -18
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +35 -25
  28. package/dist/mcp-authorization.d.ts.map +1 -1
  29. package/dist/mcp-authorization.js +34 -10
  30. package/dist/mcp-protocol.d.ts +108 -44
  31. package/dist/mcp-protocol.d.ts.map +1 -1
  32. package/dist/mcp-protocol.js +780 -484
  33. package/dist/mcp.d.ts.map +1 -1
  34. package/dist/mcp.js +129 -29
  35. package/dist/named-credentials.d.ts +19 -0
  36. package/dist/named-credentials.d.ts.map +1 -1
  37. package/dist/named-credentials.js +81 -1
  38. package/dist/oauth-request.d.ts +7 -1
  39. package/dist/oauth-request.d.ts.map +1 -1
  40. package/dist/oauth-request.js +26 -4
  41. package/dist/oauth-session.d.ts +13 -1
  42. package/dist/oauth-session.d.ts.map +1 -1
  43. package/dist/oauth-session.js +34 -18
  44. package/dist/ops.d.ts +58 -5
  45. package/dist/ops.d.ts.map +1 -1
  46. package/dist/ops.js +110 -41
  47. package/dist/resources/api-keys.d.ts +10 -7
  48. package/dist/resources/api-keys.d.ts.map +1 -1
  49. package/dist/resources/api-keys.js +10 -31
  50. package/dist/resources/deliveries.d.ts +89 -5
  51. package/dist/resources/deliveries.d.ts.map +1 -1
  52. package/dist/resources/deliveries.js +96 -19
  53. package/dist/resources/drafts.d.ts +16 -16
  54. package/dist/resources/drafts.d.ts.map +1 -1
  55. package/dist/resources/drafts.js +12 -65
  56. package/dist/resources/files.d.ts +4 -4
  57. package/dist/resources/files.d.ts.map +1 -1
  58. package/dist/resources/files.js +3 -12
  59. package/dist/resources/generations.d.ts +16 -16
  60. package/dist/resources/generations.d.ts.map +1 -1
  61. package/dist/resources/generations.js +23 -47
  62. package/dist/resources/organization.d.ts +4 -4
  63. package/dist/resources/organization.d.ts.map +1 -1
  64. package/dist/resources/organization.js +3 -10
  65. package/dist/resources/{generate.d.ts → packages.d.ts} +16 -16
  66. package/dist/resources/packages.d.ts.map +1 -0
  67. package/dist/resources/{generate.js → packages.js} +13 -29
  68. package/dist/resources/projects.d.ts +50 -50
  69. package/dist/resources/projects.d.ts.map +1 -1
  70. package/dist/resources/projects.js +60 -116
  71. package/dist/resources/releases.d.ts +22 -17
  72. package/dist/resources/releases.d.ts.map +1 -1
  73. package/dist/resources/releases.js +19 -40
  74. package/dist/resources/spec-revisions.d.ts +16 -7
  75. package/dist/resources/spec-revisions.d.ts.map +1 -1
  76. package/dist/resources/spec-revisions.js +7 -29
  77. package/dist/resources/specs.d.ts +7 -7
  78. package/dist/resources/specs.d.ts.map +1 -1
  79. package/dist/resources/specs.js +6 -34
  80. package/dist/resources/targets.d.ts +49 -49
  81. package/dist/resources/targets.d.ts.map +1 -1
  82. package/dist/resources/targets.js +59 -115
  83. package/dist/schemas.d.ts.map +1 -1
  84. package/dist/schemas.js +78 -76
  85. package/dist/search.d.ts +54 -0
  86. package/dist/search.d.ts.map +1 -0
  87. package/dist/search.js +421 -0
  88. package/dist/type-docs.d.ts +61 -0
  89. package/dist/type-docs.d.ts.map +1 -0
  90. package/dist/type-docs.js +174 -0
  91. package/dist/types.d.ts +499 -339
  92. package/dist/types.d.ts.map +1 -1
  93. package/dist/types.js +18 -18
  94. package/dist/worker.js +2 -2
  95. package/package.json +5 -2
  96. package/server.json +5 -5
  97. package/src/arguments.ts +254 -0
  98. package/src/core/http.ts +457 -58
  99. package/src/core/pagination.ts +129 -18
  100. package/src/credential-storage.ts +16 -6
  101. package/src/dates.ts +1 -1
  102. package/src/errors.ts +46 -115
  103. package/src/fields.ts +167 -0
  104. package/src/index.ts +45 -28
  105. package/src/mcp-authorization.ts +29 -9
  106. package/src/mcp-protocol.ts +808 -435
  107. package/src/mcp.ts +115 -27
  108. package/src/named-credentials.ts +66 -1
  109. package/src/oauth-request.ts +32 -6
  110. package/src/oauth-session.ts +37 -19
  111. package/src/ops.ts +146 -45
  112. package/src/resources/api-keys.ts +34 -48
  113. package/src/resources/deliveries.ts +213 -32
  114. package/src/resources/drafts.ts +62 -109
  115. package/src/resources/files.ts +19 -20
  116. package/src/resources/generations.ts +61 -79
  117. package/src/resources/organization.ts +11 -16
  118. package/src/resources/{generate.ts → packages.ts} +43 -51
  119. package/src/resources/projects.ts +145 -200
  120. package/src/resources/releases.ts +50 -67
  121. package/src/resources/spec-revisions.ts +40 -49
  122. package/src/resources/specs.ts +39 -59
  123. package/src/resources/targets.ts +144 -194
  124. package/src/schemas.ts +78 -76
  125. package/src/search.ts +434 -0
  126. package/src/type-docs.ts +205 -0
  127. package/src/types.ts +538 -357
  128. package/src/worker.ts +2 -2
  129. package/dist/resources/generate.d.ts.map +0 -1
  130. package/dist/resources/publications.d.ts +0 -47
  131. package/dist/resources/publications.d.ts.map +0 -1
  132. package/dist/resources/publications.js +0 -70
  133. package/src/resources/publications.ts +0 -140
package/src/mcp.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
- // typeship — MCP server. Generated by typeship — https://typeship.dev
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 with typeship's hosted endpoint. Two
6
- // transports, zero dependencies:
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";
26
- import { GLOBALS, OMITTED_OPS, OPS, buildArgs, type OpSpec } from "./ops.js";
25
+ import { asApiResult, mediaTypeForPath } from "./core/http.js";
26
+ import { GLOBALS, INPUT_TYPES, 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.21.0";
46
+ const SERVER_VERSION = "0.23.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 = 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(BIN + "-mcp", event) + "\n");
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": PKG_NAME + "-mcp/" + SERVER_VERSION + (MCP_CLIENT_NAME ? " (client=" + MCP_CLIENT_NAME + ")" : "") };
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" || typeof values[p.name] !== "string") continue;
190
- try { values[p.name] = fileArgument(values[p.name] as string); } catch (e) { fileIssues.push({ code: "INVALID_ARGUMENT", argument: p.name, message: p.name + ": cannot read " + String(values[p.name]) + " (" + (e as Error).message + ")" }); }
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, outputSchema: op.outputSchema };
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
- const prepared = prepareCall(op as unknown as OpLike, rawArgs, { maxChars: MAX_RESULT_CHARS });
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,8 @@ 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
+ inputTypes: INPUT_TYPES,
381
+ hiddenOps: hiddenOperations(OPS as unknown as OpLike[], { readOnly: READ_ONLY, include: INCLUDE, uploads: LOCAL_PROCESS }, { readOnly: READ_ONLY_SWITCH, include: TOOLS_SWITCH }),
311
382
  generatedOperationCount: OPS.length,
312
383
  docsUrl: () => readJson<{ docsUrl?: string }>("config.json")?.docsUrl ?? DOCS_URL_DEFAULT,
313
384
  docsIndexUrl: () => readJson<{ docsUrl?: string }>("config.json")?.docsUrl ? null : DOCS_INDEX_URL_DEFAULT,
@@ -322,9 +393,9 @@ function serverFor(remote?: RemoteContext): McpServer {
322
393
  const source = remote ? { ...docsSource, ops: operations } : docsSource;
323
394
  const website = docsSource.docsUrl();
324
395
  return {
325
- serverInfo: { name: SERVER_NAME, title: "typeship", version: SERVER_VERSION, ...(website ? { websiteUrl: website } : {}) },
396
+ serverInfo: { name: SERVER_NAME, title: "Typeship", version: SERVER_VERSION, ...(website ? { websiteUrl: website } : {}) },
326
397
  instructions: serverInstructions({
327
- title: "typeship",
398
+ title: "Typeship",
328
399
  ops: operations,
329
400
  toolCount: operations.length,
330
401
  generatedOperationCount: OPS.length,
@@ -452,12 +523,15 @@ async function authorizedHttp(request: Request, authorizer: ReturnType<typeof cr
452
523
  if (credentials.baseUrl !== undefined && credentials.baseUrl !== baseUrl) throw new Error("API credentials target a different API URL.");
453
524
  const token = request.headers.get("authorization")!.slice(7);
454
525
  const transport = credentials.fetch ?? fetch;
455
- return new TypeshipClient({ ...credentials, baseUrl, fetch: async (input, init) => {
526
+ const created = new TypeshipClient({ ...credentials, baseUrl, fetch: async (input, init) => {
456
527
  const headers = new Headers(init?.headers);
457
528
  const destination = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url);
458
529
  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
530
  return transport(input, init);
460
531
  } });
532
+ const { basicAuth, credentials: named } = credentials as unknown as { basicAuth?: unknown; credentials?: unknown };
533
+ CLIENT_BASIC.set(created, { basicAuth, credentials: named });
534
+ return created;
461
535
  })() };
462
536
 
463
537
  let incoming: unknown;
@@ -569,7 +643,21 @@ function invokedDirectly(): boolean {
569
643
  return /mcp\.(js|mjs|ts)$/.test(entry);
570
644
  }
571
645
  }
646
+ /** A --tools value that names nothing would start a server with no
647
+ * operations, which reads to an agent like an empty API. */
648
+ function toolsSwitchProblem(): string | null {
649
+ if (ARGV.includes("--tools") && INCLUDE === undefined) return "--tools needs a comma-separated list of resources, tool names, or resource.method.";
650
+ const unmatched = unmatchedIncludes(OPS as unknown as OpLike[], INCLUDE);
651
+ if (unmatched.length === 0) return null;
652
+ const resources = [...new Set(OPS.map((op) => op.resource))];
653
+ return TOOLS_SWITCH + " names nothing in this API: " + unmatched.join(", ") + ". Resources: " + resources.slice(0, 30).join(", ") + (resources.length > 30 ? ", …" : "") + ".";
654
+ }
572
655
  if (invokedDirectly()) {
656
+ const problem = toolsSwitchProblem();
657
+ if (problem !== null) {
658
+ process.stderr.write(SERVER_NAME + ": " + problem + "\n");
659
+ process.exit(2);
660
+ }
573
661
  const httpFlag = process.argv.indexOf("--http");
574
662
  if (httpFlag !== -1) {
575
663
  const port = Number(process.argv[httpFlag + 1]) || Number(process.env.PORT) || 3000;
@@ -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
+ }
@@ -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 { status: number; data?: Record<string, unknown>; error?: DeviceOAuthError }
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
- /** Error bodies and transport causes may contain credentials and are discarded. */
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 && !(mode === "device" && response.status === 400)) { void response.body?.cancel().catch(() => {}); return { status: response.status }; }
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 { throw new OAuthResponseError("invalid_response"); }
76
- if (!data || typeof data !== "object" || Array.isArray(data)) throw new OAuthResponseError("invalid_response");
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
- return { status: response.status, ...(["authorization_pending", "slow_down", "access_denied", "expired_token"].includes(String(error)) && typeof error === "string" ? { error: error as DeviceOAuthError } : {}) };
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");
@@ -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 Error("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 Error("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 Error("The credential environment or profile changed. Log in again before using saved credentials.");
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 Error("The saved login has no matching API identity verification. Sign in again to verify the current account and organization.");
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
- export class CredentialStorageError extends Error {}
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 Error("Cannot read saved credentials. Check the credential store before logging in again.");
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 Error("Cannot lock the credential store.");
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 Error("The credential store is busy. Wait for the other login or refresh to finish and retry. If its process stopped, inspect " + lock + ".");
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 Error("The saved OAuth session changed or was logged out. Run login again and retry the request.");
203
- if (session.apiBaseUrl && new URL(session.apiBaseUrl).href !== new URL(config.apiBaseUrl).href) throw new Error("The API destination changed. Log in again.");
204
- if (session.issuer && config.issuer && session.issuer !== config.issuer) throw new Error("The configured issuer changed. Log in again.");
205
- if (session.configuredClientId !== (config.clientId ?? null)) throw new Error("The OAuth client changed. Log in again.");
206
- if (!session.binding || session.binding !== sessionBinding(config)) throw new Error("The API or OAuth configuration changed. Log in again before using saved credentials.");
207
- if (session.refreshPending && !allowPending) throw new Error("The previous OAuth refresh did not finish safely. Log in again; its refresh token will not be reused.");
208
- if (typeof session.accessToken !== "string" || !session.accessToken) throw new Error("The saved OAuth session is invalid. Log in again.");
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
- const fresh = (session: StoredOAuthSession) => session.expiresAt === undefined || (Number.isFinite(session.expiresAt) && session.expiresAt > Date.now() + 60_000);
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 Error("The OAuth session expired and cannot be refreshed. Log in again.");
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 Error("The OAuth session could not be refreshed safely. Log in again.");
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
+ }