@typeship-ax/mcp 0.8.0 → 0.10.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 (103) hide show
  1. package/AGENTS.md +31 -0
  2. package/README.md +66 -9
  3. package/api.json +3153 -761
  4. package/api.md +9796 -382
  5. package/dist/api-identity.d.ts +40 -0
  6. package/dist/api-identity.d.ts.map +1 -0
  7. package/dist/api-identity.js +128 -0
  8. package/dist/auth-profiles.d.ts +30 -0
  9. package/dist/auth-profiles.d.ts.map +1 -0
  10. package/dist/auth-profiles.js +138 -0
  11. package/dist/core/http.d.ts +17 -2
  12. package/dist/core/http.d.ts.map +1 -1
  13. package/dist/core/http.js +78 -17
  14. package/dist/credential-storage.d.ts +24 -0
  15. package/dist/credential-storage.d.ts.map +1 -0
  16. package/dist/credential-storage.js +207 -0
  17. package/dist/docs.d.ts +25 -0
  18. package/dist/docs.d.ts.map +1 -1
  19. package/dist/docs.js +144 -0
  20. package/dist/errors.d.ts +18 -10
  21. package/dist/errors.d.ts.map +1 -1
  22. package/dist/errors.js +24 -14
  23. package/dist/index.d.ts +10 -3
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +20 -4
  26. package/dist/mcp-authorization.d.ts +52 -0
  27. package/dist/mcp-authorization.d.ts.map +1 -0
  28. package/dist/mcp-authorization.js +232 -0
  29. package/dist/mcp-protocol.d.ts +51 -2
  30. package/dist/mcp-protocol.d.ts.map +1 -1
  31. package/dist/mcp-protocol.js +249 -37
  32. package/dist/mcp.d.ts +21 -3
  33. package/dist/mcp.d.ts.map +1 -1
  34. package/dist/mcp.js +185 -68
  35. package/dist/named-credentials.d.ts +21 -0
  36. package/dist/named-credentials.d.ts.map +1 -0
  37. package/dist/named-credentials.js +86 -0
  38. package/dist/oauth-request.d.ts +21 -0
  39. package/dist/oauth-request.d.ts.map +1 -0
  40. package/dist/oauth-request.js +119 -0
  41. package/dist/oauth-session.d.ts +106 -0
  42. package/dist/oauth-session.d.ts.map +1 -0
  43. package/dist/oauth-session.js +244 -0
  44. package/dist/ops.d.ts +14 -1
  45. package/dist/ops.d.ts.map +1 -1
  46. package/dist/ops.js +34 -30
  47. package/dist/resources/account.d.ts +2 -2
  48. package/dist/resources/account.d.ts.map +1 -1
  49. package/dist/resources/account.js +1 -0
  50. package/dist/resources/api-keys.d.ts +3 -3
  51. package/dist/resources/api-keys.d.ts.map +1 -1
  52. package/dist/resources/api-keys.js +2 -0
  53. package/dist/resources/definition-revisions.d.ts +5 -5
  54. package/dist/resources/definition-revisions.d.ts.map +1 -1
  55. package/dist/resources/definition-revisions.js +4 -0
  56. package/dist/resources/definitions.d.ts +15 -4
  57. package/dist/resources/definitions.d.ts.map +1 -1
  58. package/dist/resources/definitions.js +11 -2
  59. package/dist/resources/generate.d.ts +14 -3
  60. package/dist/resources/generate.d.ts.map +1 -1
  61. package/dist/resources/generate.js +10 -2
  62. package/dist/resources/generations.d.ts +3 -3
  63. package/dist/resources/generations.d.ts.map +1 -1
  64. package/dist/resources/generations.js +2 -0
  65. package/dist/resources/projects.d.ts +56 -20
  66. package/dist/resources/projects.d.ts.map +1 -1
  67. package/dist/resources/projects.js +40 -4
  68. package/dist/resources/targets.d.ts +84 -10
  69. package/dist/resources/targets.d.ts.map +1 -1
  70. package/dist/resources/targets.js +127 -2
  71. package/dist/schemas.d.ts.map +1 -1
  72. package/dist/schemas.js +55 -26
  73. package/dist/types.d.ts +602 -123
  74. package/dist/types.d.ts.map +1 -1
  75. package/dist/types.js +24 -0
  76. package/dist/worker.js +4 -4
  77. package/package.json +11 -1
  78. package/server.json +42 -0
  79. package/src/api-identity.ts +98 -0
  80. package/src/auth-profiles.ts +114 -0
  81. package/src/core/http.ts +88 -19
  82. package/src/credential-storage.ts +183 -0
  83. package/src/docs.ts +138 -0
  84. package/src/errors.ts +26 -15
  85. package/src/index.ts +29 -4
  86. package/src/mcp-authorization.ts +211 -0
  87. package/src/mcp-protocol.ts +287 -38
  88. package/src/mcp.ts +186 -72
  89. package/src/named-credentials.ts +74 -0
  90. package/src/oauth-request.ts +90 -0
  91. package/src/oauth-session.ts +258 -0
  92. package/src/ops.ts +48 -31
  93. package/src/resources/account.ts +3 -0
  94. package/src/resources/api-keys.ts +5 -0
  95. package/src/resources/definition-revisions.ts +9 -0
  96. package/src/resources/definitions.ts +25 -0
  97. package/src/resources/generate.ts +23 -0
  98. package/src/resources/generations.ts +5 -0
  99. package/src/resources/projects.ts +95 -7
  100. package/src/resources/targets.ts +241 -0
  101. package/src/schemas.ts +55 -26
  102. package/src/types.ts +640 -123
  103. package/src/worker.ts +4 -4
package/src/mcp.ts CHANGED
@@ -5,17 +5,18 @@
5
5
  // live in ./mcp-protocol.js, shared with typeship's hosted endpoint. Two
6
6
  // transports, zero dependencies:
7
7
  // node mcp.js stdio (newline-delimited JSON-RPC 2.0)
8
- // node mcp.js --http Streamable HTTP on PORT (default 3000)
8
+ // createMcpHandler() configured Streamable HTTP handler
9
9
  // Surface switches (flags or environment):
10
10
  // --read-only / TYPESHIP_MCP_READ_ONLY=1 reads only; writes are not callable
11
11
  // --tools a,b / TYPESHIP_MCP_TOOLS=a,b only these resources or tools
12
12
  // TYPESHIP_MCP_MAX_RESULT_CHARS=<n> result size cap (default 64000)
13
- // handleHttp() is exported for serverless/worker runtimes.
14
- // In HTTP mode an incoming Authorization header is forwarded to the
15
- // upstream API (per-request passthrough); stdio resolves auth from the
13
+ // The bare handleHttp()/--http entry stays closed until a handler is configured.
14
+ // HTTP validates a dedicated MCP token and resolves API credentials through
15
+ // createMcpHandler's application-owned callback; stdio resolves auth from the
16
16
  // environment, then from credentials/config saved by the CLI's `login`
17
17
  // and `config` commands (same files, so one login covers both bins).
18
18
 
19
+ import { createHash } from "node:crypto";
19
20
  import { mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
20
21
  import { homedir, tmpdir } from "node:os";
21
22
  import { basename, join } from "node:path";
@@ -23,25 +24,34 @@ import { fileURLToPath } from "node:url";
23
24
  import { TypeshipClient, formatDebugEvent, type ClientOptions, type DebugEvent } from "./index.js";
24
25
  import { GLOBALS, OMITTED_OPS, OPS, buildArgs, type OpSpec } from "./ops.js";
25
26
  import {
26
- DEFAULT_MAX_RESULT_CHARS, SUPPORTED_PROTOCOL_VERSIONS, argumentsError, asJsonRpc, binaryOutcome, callSharedTool, checkRequestHeaders,
27
- dataOutcome, errorOutcome, handleRpc, isRpcOutcome, pageOutcome, parseIncludeList, prepareCall, serverInstructions,
27
+ DEFAULT_MAX_RESULT_CHARS, SUPPORTED_PROTOCOL_VERSIONS, McpAccountLinkRequired, argumentsError, asJsonRpc, binaryOutcome, callSharedTool, checkRequestHeaders,
28
+ dataOutcome, errorOutcome, handleRpc, isRpcOutcome, pageOutcome, parseIncludeList, prepareCall, resolveReferences, serverInstructions,
28
29
  takeCancelled, textError, toolDefinitions, visibleOps,
29
30
  type ArgumentIssue, type DocsSource, type McpServer, type OpLike, type RpcOutcome, type ToolOutcome,
30
31
  } from "./mcp-protocol.js";
31
32
  import { fetchDocsText } from "./docs.js";
33
+ import { assertCredentialDestination, assertStoredIdentity, oauthSessionToken } from "./oauth-session.js";
34
+ import { createCredentialStore } from "./credential-storage.js";
35
+ import { identityPolicyOf, verifyClientIdentity, type IdentityConfiguration } from "./api-identity.js";
36
+ import { parseNamedCredentials, resolveNamedCredentials, namedCredentialAvailability, type NamedCredentials, type CredentialSchemes } from "./named-credentials.js";
37
+ import { resolveProfile, profileFlag, readProfileConfig } from "./auth-profiles.js";
38
+ import { createMcpAuthorizer, McpAuthorizationError, type McpAuthorizationConfiguration, type McpTokenIntrospectionConfiguration, type McpPrincipal } from "./mcp-authorization.js";
39
+ export type { McpAuthorizationConfiguration, McpTokenIntrospectionConfiguration, McpPrincipal } from "./mcp-authorization.js";
40
+ export { McpAccountLinkRequired } from "./mcp-protocol.js";
32
41
 
33
42
  const BIN = "typeship";
34
43
  const PKG_NAME = "@typeship-ax/mcp";
35
44
  const SERVER_NAME = "typeship-mcp";
36
- const SERVER_VERSION = "0.8.0";
45
+ const SERVER_VERSION = "0.10.0";
37
46
  /** The MCP client's announced name (clientInfo in request _meta), for the User-Agent. */
38
47
  let MCP_CLIENT_NAME: string | null = null;
39
48
  function noteClientInfo(message: unknown): void {
40
49
  const meta = (message as { params?: { _meta?: Record<string, { name?: unknown }> } } | null)?.params?._meta;
41
50
  const name = meta?.["io.modelcontextprotocol/clientInfo"]?.name;
42
- if (typeof name === "string" && name && name !== MCP_CLIENT_NAME) { MCP_CLIENT_NAME = name.slice(0, 60); clientInstance = undefined; }
51
+ if (typeof name === "string" && name && name !== MCP_CLIENT_NAME) { MCP_CLIENT_NAME = name.slice(0, 60); }
43
52
  }
44
53
  const DEFAULT_BASE_URL = "https://typeship.dev/api/v1";
54
+ const NAMED_SCHEMES: CredentialSchemes = {"apiKey":{"kind":"bearer","options":["bearerToken"]}};
45
55
  const AUTH_SCALARS: { option: string; flag: string; env: string }[] = [{"option":"bearerToken","flag":"token","env":"TYPESHIP_TOKEN"}];
46
56
  const BASIC: { envUser: string; envPass: string } | null = null;
47
57
 
@@ -51,8 +61,6 @@ const DOCS_INDEX_URL_DEFAULT: string | null = null;
51
61
  /** "meta" collapses per-operation tools into search/read/execute so huge
52
62
  * APIs don't flood agent context with hundreds of tools. */
53
63
  const TOOL_MODE: "operations" | "meta" = "meta";
54
- /** Authorization server for OAuth discovery (RFC 9728), from the spec. */
55
- const OAUTH_ISSUER: string | null = null;
56
64
  /** Project-supplied guidance appended to the server instructions. */
57
65
  const CUSTOM_INSTRUCTIONS: string | null = null;
58
66
  /** The tool that returns the caller (the CLI's whoami target), named in the instructions. */
@@ -65,17 +73,20 @@ const INCLUDE = parseIncludeList(ARGV.includes("--tools") ? ARGV[ARGV.indexOf("-
65
73
  const MAX_RESULT_CHARS = Number(process.env["TYPESHIP_MCP_MAX_RESULT_CHARS"]) || DEFAULT_MAX_RESULT_CHARS;
66
74
  /** One sentence on where credentials come from on each transport; goes
67
75
  * into the instructions and into 401 results. */
68
- const AUTH_HINT_STDIO = "Credentials come from the MCP server's environment (TYPESHIP_TOKEN) or from 'typeship login'";
69
- const AUTH_HINT_HTTP = "Send the API credential as the Authorization header of each MCP request; it is forwarded to the API as is";
76
+ const AUTH_HINT_STDIO = "Credentials come from the MCP server's environment (TYPESHIP_CREDENTIALS, TYPESHIP_TOKEN) or from 'typeship login'";
77
+ const AUTH_HINT_HTTP = "Sign in to this MCP server. The server resolves your API credentials separately; its connection token is never forwarded to the API.";
70
78
  /** The tool list is fixed at generation, so clients may cache it for an
71
79
  * hour and shared caches may hold it (identical for every caller). */
72
80
  const TOOLS_TTL_MS = 60 * 60 * 1000;
73
81
 
74
- function configDir(): string {
82
+ function configRoot(): string {
75
83
  return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), BIN);
76
84
  }
77
85
 
86
+ function configDir(): string { return resolveProfile(configRoot(), { flag: profileFlag(ARGV), environment: process.env["TYPESHIP_PROFILE"] }).directory; }
87
+
78
88
  function readJson<T>(file: string): T | null {
89
+ if (!LOCAL_CREDENTIALS) return null;
79
90
  try {
80
91
  return JSON.parse(readFileSync(join(configDir(), file), "utf8")) as T;
81
92
  } catch {
@@ -83,13 +94,21 @@ function readJson<T>(file: string): T | null {
83
94
  }
84
95
  }
85
96
 
86
- function makeClient(): TypeshipClient {
87
- const stored = readJson<{
88
- scalars?: Record<string, string>;
89
- basic?: { username: string; password: string };
90
- oauth?: { accessToken: string };
91
- }>("credentials.json");
92
- const config = readJson<{ baseUrl?: string; environment?: string }>("config.json");
97
+ function makeClient(op: OpSpec): TypeshipClient {
98
+ const profile = LOCAL_CREDENTIALS ? resolveProfile(configRoot(), { flag: profileFlag(ARGV), environment: process.env["TYPESHIP_PROFILE"] }) : null;
99
+ const store = createCredentialStore(profile?.directory ?? configRoot(), process.env["TYPESHIP_CREDENTIAL_STORE"], "TYPESHIP_CREDENTIAL_STORE");
100
+ 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.");
101
+ const envNamed = LOCAL_CREDENTIALS && process.env["TYPESHIP_CREDENTIALS"] !== undefined ? parseNamedCredentials(process.env["TYPESHIP_CREDENTIALS"], NAMED_SCHEMES) : {};
102
+ const explicitOptions = new Set(AUTH_SCALARS.filter((a) => process.env[a.env] !== undefined).map((a) => a.option));
103
+ if (BASIC && process.env[BASIC.envUser] !== undefined && process.env[BASIC.envPass] !== undefined) explicitOptions.add("basicAuth");
104
+
105
+ namedCredentialAvailability(NAMED_SCHEMES, explicitOptions, envNamed);
106
+ const identityPolicy = {};
107
+ const identityOp = OPS.find((candidate) => candidate.tool === IDENTITY_TOOL);
108
+ const identity: IdentityConfiguration | undefined = identityOp && Object.keys(identityPolicy).length ? { operation: identityOp.resource + "." + identityOp.method, fields: identityPolicy } : undefined;
109
+ const explicitCredentials = op.credentialOptions?.some((alternative) => alternative.length > 0 && alternative.every((option) => explicitOptions.has(option)));
110
+ const stored = op.auth !== "none" && LOCAL_CREDENTIALS && !explicitCredentials ? store.read() : null;
111
+ const config = profile ? readProfileConfig(profile.directory) : {};
93
112
  const baseUrl = process.env["TYPESHIP_BASE_URL"]
94
113
  ?? config?.baseUrl
95
114
  ?? (config?.environment !== undefined ? ENVIRONMENTS[config.environment] : undefined)
@@ -99,6 +118,7 @@ function makeClient(): TypeshipClient {
99
118
  // instead of a server that vanished mid-conversation.
100
119
  throw new Error("No base URL configured: set TYPESHIP_BASE_URL in the MCP server's environment, or run '" + BIN + " config set base-url <url>'.");
101
120
  }
121
+ if (stored) { assertCredentialDestination(stored, { apiBaseUrl: baseUrl, environment: config.environment, profile: profile?.name }); assertStoredIdentity(stored, identity); }
102
122
  const options: ClientOptions & Record<string, unknown> = { baseUrl };
103
123
  for (const a of AUTH_SCALARS) {
104
124
  const v = process.env[a.env] ?? stored?.scalars?.[a.option];
@@ -109,10 +129,15 @@ function makeClient(): TypeshipClient {
109
129
  const password = process.env[BASIC.envPass] ?? stored?.basic?.password;
110
130
  if (username !== undefined && password !== undefined) options.basicAuth = { username, password };
111
131
  }
132
+ const envOptions: Record<string, unknown> = {};
133
+ for (const a of AUTH_SCALARS) if (process.env[a.env] !== undefined) envOptions[a.option] = process.env[a.env];
134
+ if (BASIC && process.env[BASIC.envUser] !== undefined && process.env[BASIC.envPass] !== undefined) envOptions.basicAuth = { username: process.env[BASIC.envUser], password: process.env[BASIC.envPass] };
135
+ options.credentials = resolveNamedCredentials(NAMED_SCHEMES, [
136
+ { named: stored?.named, options: { ...stored?.scalars, ...(stored?.basic ? { basicAuth: stored.basic } : {}) } },
137
+ { named: envNamed, options: envOptions },
138
+ ]);
139
+
112
140
 
113
- if (options.bearerToken === undefined && stored?.oauth?.accessToken !== undefined) {
114
- options.bearerToken = stored.oauth.accessToken;
115
- }
116
141
  for (const g of GLOBALS) {
117
142
  const value = process.env["TYPESHIP_" + g.envSuffix];
118
143
  if (value !== undefined) options[g.option] = value;
@@ -122,17 +147,27 @@ function makeClient(): TypeshipClient {
122
147
  }
123
148
  // The local MCP server identifies itself (surface + the client it serves, when announced).
124
149
  options.defaultHeaders = { "User-Agent": PKG_NAME + "-mcp/" + SERVER_VERSION + " (typeship" + (MCP_CLIENT_NAME ? "; client=" + MCP_CLIENT_NAME : "") + ")" };
125
- return new TypeshipClient(options);
150
+ // Keep the SDK's machine-token cache while re-evaluating local configuration.
151
+ const key = createHash("sha256").update(JSON.stringify([options, config, profile?.name, stored?.oauth?.sessionId, null, process.env["TYPESHIP_DEBUG"]])).digest("hex");
152
+ if (clientInstance && clientKey === key) return clientInstance;
153
+ const client = new TypeshipClient(options);
154
+ clientKey = key;
155
+ 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);
156
+ return (clientInstance = client);
126
157
  }
127
158
 
128
159
  let clientInstance: TypeshipClient | undefined;
129
- function getClient(): TypeshipClient {
130
- return (clientInstance ??= makeClient());
160
+ let clientKey = "";
161
+ const CLIENT_CREDENTIALS = new WeakMap<TypeshipClient, boolean>();
162
+
163
+ /** Resolve configuration and credentials for each tool call, including login/logout changes. */
164
+ function getClient(op: OpSpec): TypeshipClient {
165
+ return makeClient(op);
131
166
  }
132
167
 
133
168
  /** Run one operation through the generated SDK: the same client, the same
134
169
  * typed errors and pagination a hand-written caller would get. */
135
- async function callOperation(op: OpSpec, rawArgs: Record<string, unknown>, authHeader?: string): Promise<ToolOutcome> {
170
+ async function callOperationRaw(op: OpSpec, rawArgs: Record<string, unknown>, remote: RemoteContext | undefined, client: TypeshipClient): Promise<ToolOutcome> {
136
171
  // Arguments are checked against the tool's schema first: unknown names,
137
172
  // wrong types and missing requirements come back as one isError result,
138
173
  // nothing reaches the API half-formed and nothing is dropped silently.
@@ -147,7 +182,7 @@ async function callOperation(op: OpSpec, rawArgs: Record<string, unknown>, authH
147
182
  // argument errors, reported before anything is sent.
148
183
  const fileIssues: ArgumentIssue[] = [];
149
184
  let rawBody: unknown = op.bodyStyle === "data" ? args.body : undefined;
150
- if (LOCAL_PROCESS) {
185
+ if (LOCAL_PROCESS && !remote) {
151
186
  for (const p of op.params) {
152
187
  if (p.type !== "file" || typeof values[p.name] !== "string") continue;
153
188
  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 + ")" }); }
@@ -163,15 +198,14 @@ async function callOperation(op: OpSpec, rawArgs: Record<string, unknown>, authH
163
198
  rawBody,
164
199
  typeof args.select === "string" ? args.select : undefined,
165
200
  );
166
- if (authHeader) callArgs.push({ headers: { Authorization: authHeader } });
167
201
  const errorContext = {
168
- authHint: authHeader !== undefined ? AUTH_HINT_HTTP : AUTH_HINT_STDIO,
202
+ authHint: remote ? AUTH_HINT_HTTP : AUTH_HINT_STDIO,
169
203
  docsUrl: docsSource.docsUrl(),
170
- hadCredential: authHeader !== undefined || AUTH_SCALARS.some((a) => process.env[a.env] !== undefined) || readJson<{ scalars?: unknown; oauth?: unknown; basic?: unknown }>("credentials.json") !== null,
204
+ hadCredential: !!remote || CLIENT_CREDENTIALS.get(client) === true,
171
205
  };
172
206
  const shape = { fields, maxChars, pagination: op.pagination, args };
173
207
  try {
174
- const target = (getClient() as unknown as Record<string, Record<string, (...a: unknown[]) => unknown>>)[op.resource]!;
208
+ const target = (client as unknown as Record<string, Record<string, (...a: unknown[]) => unknown>>)[op.resource]!;
175
209
  const result = await (target[op.method]!(...callArgs) as Promise<{ ok: boolean; data?: unknown; error?: unknown; response?: { requestId?: string } }>);
176
210
  if (!result.ok) return errorOutcome(result.error, errorContext);
177
211
  if (op.paginated) {
@@ -180,17 +214,57 @@ async function callOperation(op: OpSpec, rawArgs: Record<string, unknown>, authH
180
214
  }
181
215
  // A binary body (the SDK hands back a Blob): an image block, or a file
182
216
  // on disk, never "{}".
183
- if (result.data instanceof Blob) return binaryOutcome(result.data, { tool: op.tool, ...(LOCAL_PROCESS ? { saveBinary } : {}) });
217
+ if (result.data instanceof Blob) return binaryOutcome(result.data, { tool: op.tool, ...(LOCAL_PROCESS && !remote ? { saveBinary } : {}) });
184
218
  return dataOutcome(result.data, shape);
185
219
  } catch (e) {
186
220
  return textError((e as Error).message ?? "Tool call failed");
187
221
  }
188
222
  }
189
223
 
224
+ /** Name-or-ID resolution is the one wrapper around the ordinary executor,
225
+ * so a direct operation tool and compact execute take exactly the same path. */
226
+ const REFERENCE_CACHES = new Map<string, Map<string, string | number>>();
227
+ function referenceCacheFor(authHeader?: string): Map<string, string | number> {
228
+ const key = createHash("sha256").update(JSON.stringify([authHeader ?? "stdio", clientKey])).digest("hex");
229
+ let cache = REFERENCE_CACHES.get(key);
230
+ if (!cache) {
231
+ cache = new Map();
232
+ REFERENCE_CACHES.set(key, cache);
233
+ while (REFERENCE_CACHES.size > 16) REFERENCE_CACHES.delete(REFERENCE_CACHES.keys().next().value!);
234
+ }
235
+ return cache;
236
+ }
237
+
238
+ async function callOperation(op: OpSpec, rawArgs: Record<string, unknown>, remote?: RemoteContext): Promise<ToolOutcome> {
239
+ const prepared = prepareCall(op as unknown as OpLike, rawArgs, { maxChars: MAX_RESULT_CHARS });
240
+ if (!prepared.ok) return prepared.outcome;
241
+ // One client/session for the entire tool, including name-to-ID lookups.
242
+ // If login changes during a lookup, the token callback rejects the old session.
243
+ let client: TypeshipClient;
244
+ try { client = remote ? await remote.client() : getClient(op); } catch (error) {
245
+ if (remote && error instanceof McpAccountLinkRequired) throw error;
246
+ return textError(remote ? "API access is unavailable for this MCP account. Reconnect your API account or contact the server owner." : (error as Error).message);
247
+ }
248
+ const resolved = await resolveReferences(op as unknown as OpLike, prepared.call.args, {
249
+ ops: OPS as unknown as OpLike[],
250
+ identityTool: IDENTITY_TOOL,
251
+ cache: remote?.references ?? referenceCacheFor(),
252
+ runOperation: (source, args) => callOperationRaw(source as OpSpec, args, remote, client),
253
+ });
254
+ if (!resolved.ok) return resolved.outcome;
255
+ const args = {
256
+ ...resolved.args,
257
+ ...(prepared.call.fields ? { fields: prepared.call.fields.map((path) => path.join(".")) } : {}),
258
+ };
259
+ return callOperationRaw(op, args, remote, client);
260
+ }
261
+
190
262
  /** Running as a process on this machine (stdio, or --http launched here),
191
263
  * as opposed to imported by a worker: the server can read and write local
192
264
  * files, so uploads are tools and binaries are saved to disk. */
193
265
  const LOCAL_PROCESS = invokedDirectly();
266
+ /** Saved human sessions are available only to local stdio, never HTTP/worker callers. */
267
+ const LOCAL_CREDENTIALS = LOCAL_PROCESS && !ARGV.includes("--http");
194
268
  /** The callable operations: event streams are CLI-only, uploads are tools
195
269
  * only on a local server (mcpExposed), writes are out under --read-only,
196
270
  * and --tools narrows to a subset. A hidden operation is unknown to
@@ -222,39 +296,44 @@ const docsSource: DocsSource = {
222
296
  fetchText: fetchDocsText,
223
297
  };
224
298
 
225
- /** The MCP server for one request context (the HTTP transport passes the
226
- * caller's Authorization through; stdio has none). */
227
- function serverFor(authHeader?: string): McpServer {
299
+ /** One validated remote identity and API client per HTTP request. */
300
+ interface RemoteContext { client(): Promise<TypeshipClient>; references: Map<string, string | number> }
301
+
302
+ function serverFor(remote?: RemoteContext): McpServer {
303
+ const operations = remote ? visibleOps(MCP_OPS as unknown as OpLike[], { uploads: false }) : MCP_OPS as unknown as OpLike[];
304
+ const source = remote ? { ...docsSource, ops: operations } : docsSource;
228
305
  const website = docsSource.docsUrl();
229
306
  return {
230
307
  serverInfo: { name: SERVER_NAME, title: "typeship", version: SERVER_VERSION, ...(website ? { websiteUrl: website } : {}) },
231
308
  instructions: serverInstructions({
232
309
  title: "typeship",
233
- toolCount: MCP_OPS.length,
310
+ ops: operations,
311
+ toolCount: operations.length,
234
312
  generatedOperationCount: OPS.length,
235
313
  omittedOps: docsSource.omittedOps,
236
314
  mode: TOOL_MODE,
237
315
  readOnly: READ_ONLY,
238
316
  hiddenWrites: HIDDEN_WRITES,
239
- authHint: authHeader !== undefined ? AUTH_HINT_HTTP : AUTH_HINT_STDIO,
317
+ authHint: remote ? AUTH_HINT_HTTP : AUTH_HINT_STDIO,
240
318
  identityTool: MCP_OPS.some((o) => o.tool === IDENTITY_TOOL) ? IDENTITY_TOOL : null,
241
- uploads: HAS_UPLOADS,
319
+ referenceResolution: MCP_OPS.some((o) => o.params.some((p) => p.resolve && typeof p.resolve === "object")),
320
+ uploads: !remote && HAS_UPLOADS,
242
321
  custom: CUSTOM_INSTRUCTIONS,
243
322
  }),
244
323
  toolsTtlMs: TOOLS_TTL_MS,
245
- listTools: () => toolDefinitions(docsSource.ops, TOOL_MODE, docsSource.omittedOps),
324
+ listTools: () => toolDefinitions(source.ops, TOOL_MODE, source.omittedOps),
246
325
  unknownToolMessage: TOOL_MODE === "meta"
247
326
  ? (name) => "Unknown tool: " + name + ". This server uses compact mode; call search_docs to discover an operation, then call execute with operation: \"" + name + "\"."
248
327
  : undefined,
249
328
  callTool: async (name, args) => {
250
- const shared = await callSharedTool(name, args, docsSource, (op, opArgs) => callOperation(op as OpSpec, opArgs, authHeader));
329
+ const shared = await callSharedTool(name, args, source, (op, opArgs) => callOperation(op as OpSpec, opArgs, remote));
251
330
  if (shared !== undefined) return shared;
252
331
  // tools/list is the callable contract. In meta mode every operation
253
332
  // goes through execute, including its destructive-operation
254
333
  // confirmation gate; an unlisted operation name must stay unknown.
255
334
  if (TOOL_MODE !== "operations") return undefined;
256
- const op = MCP_OPS.find((o) => o.tool === name);
257
- return op ? callOperation(op, args, authHeader) : undefined;
335
+ const op = operations.find((o) => o.tool === name);
336
+ return op ? callOperation(op as OpSpec, args, remote) : undefined;
258
337
  },
259
338
  };
260
339
  }
@@ -266,15 +345,15 @@ function serverFor(authHeader?: string): McpServer {
266
345
  * validating the header when present (DNS-rebinding defense), so browser
267
346
  * callers must be same-host, localhost, or listed in
268
347
  * TYPESHIP_MCP_ALLOWED_ORIGINS (comma-separated, "*" for any). */
269
- function originAllowed(request: Request): boolean {
348
+ function originAllowed(request: Request, resourceOrigin: string): boolean {
270
349
  const origin = request.headers.get("origin");
271
350
  if (origin === null) return true;
272
351
  const allowed = (process.env["TYPESHIP_MCP_ALLOWED_ORIGINS"] ?? "").split(",").map((s) => s.trim()).filter(Boolean);
273
352
  if (allowed.includes("*") || allowed.includes(origin)) return true;
274
353
  try {
275
354
  const o = new URL(origin);
276
- if (o.host === new URL(request.url).host) return true;
277
- return ["localhost", "127.0.0.1", "[::1]"].includes(o.hostname);
355
+ if (o.origin === resourceOrigin) return true;
356
+ return ["localhost", "127.0.0.1", "[::1]"].includes(new URL(resourceOrigin).hostname) && ["localhost", "127.0.0.1", "[::1]"].includes(o.hostname);
278
357
  } catch {
279
358
  return false;
280
359
  }
@@ -283,50 +362,85 @@ function originAllowed(request: Request): boolean {
283
362
  function corsHeaders(request: Request): Record<string, string> {
284
363
  return {
285
364
  "Access-Control-Allow-Origin": request.headers.get("origin") ?? "*",
286
- "Access-Control-Allow-Methods": "POST, OPTIONS",
365
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
366
+ "Access-Control-Expose-Headers": "WWW-Authenticate",
287
367
  "Access-Control-Allow-Headers": "Content-Type, Authorization, Accept, MCP-Protocol-Version, Mcp-Method, Mcp-Name",
288
368
  Vary: "Origin",
289
369
  };
290
370
  }
291
371
 
292
- function protectedResourceMetadata(origin: string): Record<string, unknown> {
293
- return {
294
- resource: origin,
295
- ...(OAUTH_ISSUER ? { authorization_servers: [OAUTH_ISSUER] } : {}),
296
- bearer_methods_supported: ["header"],
297
- };
372
+ export interface McpHttpOptions {
373
+ authorization?: McpAuthorizationConfiguration;
374
+ /** Optional server-only introspection. When set, checks every token with the
375
+ * provider instead of local JWT verification. Never put secrets in config. */
376
+ introspection?: McpTokenIntrospectionConfiguration;
377
+ /** Application-owned lookup or token exchange for this verified identity.
378
+ * Keep it outside the generated directory so regeneration preserves it.
379
+ * Throw McpAccountLinkRequired with a public linking page when the user
380
+ * needs to connect an account. Always verify the link server-side on retry.
381
+ * The MCP access token and request headers are deliberately not provided. */
382
+ credentialsFor(principal: McpPrincipal): ClientOptions | Promise<ClientOptions>;
298
383
  }
299
384
 
300
- /** Fetch-style handler: mount in any runtime (workers, serverless, node).
301
- * Never rejects: every failure is an HTTP or JSON-RPC error response. */
302
- export async function handleHttp(request: Request): Promise<Response> {
385
+ /** Create once at startup; metadata/key caching is isolated to this server. */
386
+ export function createMcpHandler(options: McpHttpOptions): (request: Request) => Promise<Response> {
387
+ const configured = {} as { mcpIssuer?: string | null; mcpResource?: string | null; mcpJwksUrl?: string | null; mcpScopes?: string[] | null };
388
+ const apiBaseUrl = process.env["TYPESHIP_BASE_URL"] ?? DEFAULT_BASE_URL ?? undefined;
389
+ const authorizer = createMcpAuthorizer(options.authorization ?? { issuer: configured.mcpIssuer ?? "", resource: configured.mcpResource ?? "", ...(configured.mcpJwksUrl ? { jwksUrl: configured.mcpJwksUrl } : {}), scopes: configured.mcpScopes ?? [] }, options.introspection);
390
+ if (typeof options.credentialsFor !== "function") throw new Error("Provide credentialsFor to resolve each MCP caller's upstream API credentials.");
391
+ return (request) => authorizedHttp(request, authorizer, options.credentialsFor, apiBaseUrl).catch(() => new Response(JSON.stringify({ error: "MCP request failed." }), { status: 500, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } }));
392
+ }
393
+
394
+ /** A generated entry point cannot infer the host application's user-to-API
395
+ * credential mapping. It remains closed until the owner installs a handler. */
396
+ export async function handleHttp(_request: Request): Promise<Response> {
397
+ return new Response(JSON.stringify({ error: "Configure createMcpHandler with MCP authorization and credentialsFor before serving remote requests." }), { status: 503, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
398
+ }
399
+
400
+ async function authorizedHttp(request: Request, authorizer: ReturnType<typeof createMcpAuthorizer>, credentialsFor: McpHttpOptions["credentialsFor"], apiBaseUrl: string | undefined): Promise<Response> {
303
401
  const url = new URL(request.url);
304
- const cors = corsHeaders(request);
402
+ const resource = new URL(authorizer.configuration.resource);
403
+ const cors = originAllowed(request, resource.origin) ? corsHeaders(request) : {};
305
404
  const json = (status: number, body: unknown, extra: Record<string, string> = {}) =>
306
- new Response(JSON.stringify(body), { status, headers: { ...cors, "Content-Type": "application/json", ...extra } });
405
+ new Response(JSON.stringify(body), { status, headers: { ...cors, "Content-Type": "application/json", "Cache-Control": "no-store", ...extra } });
307
406
 
308
407
  if (request.method === "OPTIONS") {
309
- return new Response(null, { status: originAllowed(request) ? 204 : 403, headers: cors });
408
+ return new Response(null, { status: originAllowed(request, resource.origin) ? 204 : 403, headers: cors });
310
409
  }
311
- if (!originAllowed(request)) {
410
+ if (!originAllowed(request, resource.origin)) {
312
411
  return json(403, { jsonrpc: "2.0", error: { code: -32600, message: "Origin not allowed. Set TYPESHIP_MCP_ALLOWED_ORIGINS to permit browser callers." } });
313
412
  }
314
- if (url.pathname.endsWith("/.well-known/oauth-protected-resource")) {
315
- return json(200, protectedResourceMetadata(url.origin));
316
- }
413
+ const metadataPath = "/.well-known/oauth-protected-resource" + resource.pathname.replace(/\/$/, "");
414
+ if (url.pathname === metadataPath && request.method === "GET") return json(200, authorizer.metadata());
415
+ if (url.pathname !== resource.pathname) return json(404, { error: "Unknown MCP endpoint." });
317
416
  if (request.method !== "POST") {
318
417
  // GET streams, DELETE (session end) and Mcp-Session-Id belong to earlier
319
418
  // revisions; the spec asks modern-only servers to answer 405.
320
419
  return json(405, { error: "POST JSON-RPC messages to this endpoint (MCP " + SUPPORTED_PROTOCOL_VERSIONS[0] + ", stateless)." }, { Allow: "POST, OPTIONS" });
321
420
  }
322
421
 
323
- const authHeader = request.headers.get("authorization") ?? undefined;
324
- const envHasAuth = AUTH_SCALARS.some((a) => process.env[a.env] !== undefined);
325
- if (OAUTH_ISSUER && !authHeader && !envHasAuth) {
326
- return json(401, { error: "Authorization required." }, {
327
- "WWW-Authenticate": 'Bearer resource_metadata="' + url.origin + '/.well-known/oauth-protected-resource"',
328
- });
422
+ let principal: McpPrincipal;
423
+ try { principal = await authorizer.authorize(request.headers.get("authorization")); }
424
+ catch (error) {
425
+ const failure = error instanceof McpAuthorizationError ? error : new McpAuthorizationError(503, "authorization_unavailable");
426
+ return json(failure.status, { error: failure.code, message: failure.message }, failure.status === 503 ? {} : { "WWW-Authenticate": authorizer.challenge(failure.code === "insufficient_scope" ? "insufficient_scope" : "invalid_token") });
329
427
  }
428
+ let client: Promise<TypeshipClient> | undefined;
429
+ const remote: RemoteContext = { references: new Map(), client: () => client ??= (async () => {
430
+ const credentials = await credentialsFor(principal);
431
+ if (!credentials || typeof credentials !== "object") throw new Error("API credentials unavailable.");
432
+ const baseUrl = apiBaseUrl;
433
+ if (typeof baseUrl !== "string") throw new Error("Configure the upstream API URL.");
434
+ if (credentials.baseUrl !== undefined && credentials.baseUrl !== baseUrl) throw new Error("API credentials target a different API URL.");
435
+ const token = request.headers.get("authorization")!.slice(7);
436
+ const transport = credentials.fetch ?? fetch;
437
+ return new TypeshipClient({ ...credentials, baseUrl, fetch: async (input, init) => {
438
+ const headers = new Headers(init?.headers);
439
+ const destination = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url);
440
+ 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.");
441
+ return transport(input, init);
442
+ } });
443
+ })() };
330
444
 
331
445
  let incoming: unknown;
332
446
  try {
@@ -345,9 +459,9 @@ export async function handleHttp(request: Request): Promise<Response> {
345
459
  noteClientInfo(parsed);
346
460
  let outcome: RpcOutcome;
347
461
  try {
348
- outcome = await handleRpc(serverFor(authHeader), parsed);
462
+ outcome = await handleRpc(serverFor(remote), parsed);
349
463
  } catch (e) {
350
- return json(500, { jsonrpc: "2.0", id: parsed.id ?? null, error: { code: -32603, message: (e as Error).message ?? "Internal error" } });
464
+ return json(500, { jsonrpc: "2.0", id: parsed.id ?? null, error: { code: -32603, message: "Internal error" } });
351
465
  }
352
466
  if (outcome.message === null) {
353
467
  return new Response(null, { status: outcome.status, headers: cors });
@@ -417,7 +531,7 @@ async function startStdio(): Promise<void> {
417
531
  })
418
532
  .catch((e) => {
419
533
  if (id !== undefined) {
420
- write({ jsonrpc: "2.0", id: id ?? null, error: { code: -32603, message: (e as Error).message ?? "Internal error" } });
534
+ write({ jsonrpc: "2.0", id: id ?? null, error: { code: -32603, message: "Internal error" } });
421
535
  }
422
536
  });
423
537
  });
@@ -0,0 +1,74 @@
1
+ import { openSync, readSync, closeSync } from "node:fs";
2
+
3
+ /** Runtime credentials keyed by the API's exact security-scheme names. */
4
+ export type NamedCredential = string | { username: string; password: string };
5
+ export type NamedCredentials = Record<string, NamedCredential>;
6
+ export type CredentialSchemes = Record<string, { kind: string; options: string[] }>;
7
+
8
+ export function parseNamedCredentials(value: unknown, schemes: CredentialSchemes): NamedCredentials {
9
+ if (typeof value === "string") {
10
+ if (new TextEncoder().encode(value).byteLength > 1_048_576) throw new Error("Named credentials exceed the 1 MiB limit.");
11
+ try { value = JSON.parse(value); } catch { throw new Error("Named credentials must be a JSON object keyed by security scheme. Credential contents are not included in errors."); }
12
+ }
13
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Named credentials must be a JSON object keyed by security scheme.");
14
+ const result: NamedCredentials = Object.create(null);
15
+ for (const [name, credential] of Object.entries(value)) {
16
+ if (!Object.hasOwn(schemes, name)) throw new Error("Named credentials contain an unknown or unsupported security scheme. Check the names listed by login --help.");
17
+ if (schemes[name]!.kind === "basic") {
18
+ if (!credential || typeof credential !== "object" || Array.isArray(credential) || Object.keys(credential).some((key) => key !== "username" && key !== "password") ||
19
+ typeof credential.username !== "string" || typeof credential.password !== "string" || !credential.username || !credential.password || credential.username.includes(":")) {
20
+ throw new Error("A named Basic credential requires a nonempty username and password; the username cannot contain a colon.");
21
+ }
22
+ result[name] = { username: credential.username, password: credential.password };
23
+ } else {
24
+ if (typeof credential !== "string" || !credential || /[\x00-\x1f\x7f]/.test(credential) || (schemes[name]!.kind === "bearer" && credential.includes(" "))) throw new Error("A named token or API key must be a nonempty string without control characters; bearer tokens cannot contain spaces.");
25
+ result[name] = credential;
26
+ }
27
+ }
28
+ return result;
29
+ }
30
+
31
+ /** Low-to-high source order. Within a source, exact names beat convenience options. */
32
+ export function resolveNamedCredentials(schemes: CredentialSchemes, layers: { named?: NamedCredentials; options?: Record<string, unknown> }[]): NamedCredentials {
33
+ const resolved: NamedCredentials = Object.create(null);
34
+ for (const layer of layers) {
35
+ for (const [name, scheme] of Object.entries(schemes)) {
36
+ for (const option of scheme.options) {
37
+ if (option === "clientCredentials") continue;
38
+ const value = layer.options?.[option];
39
+ if (value !== undefined) resolved[name] = value as NamedCredential;
40
+ }
41
+ }
42
+ if (layer.named) Object.assign(resolved, parseNamedCredentials(layer.named, schemes));
43
+ }
44
+ return resolved;
45
+ }
46
+
47
+ /** Make convenience and named inputs comparable without expanding combinations. */
48
+ export function namedCredentialAvailability(schemes: CredentialSchemes, options: Set<string>, named: NamedCredentials): Set<string> {
49
+ for (const [name, scheme] of Object.entries(schemes)) {
50
+ if (Object.hasOwn(named, name) || scheme.options.some((option) => options.has(option))) options.add("credentials." + name);
51
+ }
52
+ return options;
53
+ }
54
+
55
+ /** Bound file/stdin reads before allocating a full credential document. */
56
+ export function readNamedCredentialsFile(input: string, schemes: CredentialSchemes): NamedCredentials {
57
+ let fd: number | undefined;
58
+ const chunks: Buffer[] = [];
59
+ let total = 0;
60
+ try {
61
+ fd = input === "-" ? 0 : openSync(input.slice(1), "r");
62
+ while (true) {
63
+ const buffer = Buffer.alloc(Math.min(32_768, 1_048_577 - total));
64
+ const size = readSync(fd, buffer, 0, buffer.length, null);
65
+ if (!size) break;
66
+ total += size;
67
+ if (total > 1_048_576) throw new Error("size");
68
+ chunks.push(buffer.subarray(0, size));
69
+ }
70
+ } catch {
71
+ throw new Error("Cannot read named credentials. Supply a readable JSON file or stdin input of at most 1 MiB.");
72
+ } finally { if (fd !== undefined && input !== "-") closeSync(fd); }
73
+ return parseNamedCredentials(Buffer.concat(chunks).toString("utf8"), schemes);
74
+ }