@cjhyy/code-shell-link 0.9.44 → 0.9.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/catalog.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { FIGMA_MCP_ACTION_IDS } from "./figma.js";
1
2
  const text = (zh, en) => ({ zh, en });
2
3
  function localToken(id, displayName, tokenLabel, tokenPlaceholder, authGuide) {
3
4
  return {
@@ -377,7 +378,7 @@ export const LINK_PROVIDER_MANIFESTS = [
377
378
  id: "figma",
378
379
  displayName: "Figma",
379
380
  category: "design",
380
- description: text("按需读取设计结构、节点、渲染图、填充图片和评论。", "Read design structure, nodes, rendered images, image fills, and comments on demand."),
381
+ description: text("API 读取原始节点、图片和评论;需要设计上下文、动画、组件映射或 Make 源码时使用官方 MCP。", "Use the API for nodes, images, and comments; use official MCP when design context, motion, code mappings, or Make source is needed."),
381
382
  brandText: "Fi",
382
383
  icon: "figma",
383
384
  accent: "violet",
@@ -400,9 +401,34 @@ export const LINK_PROVIDER_MANIFESTS = [
400
401
  note: text("Token 只能访问你的 Figma 账号本身有权限的文件。", "The token can only access files already available to your Figma account."),
401
402
  }),
402
403
  { ...managedOAuth("Figma"), id: "figma-oauth" },
404
+ {
405
+ id: "mcp",
406
+ displayName: text("官方 MCP", "Official MCP"),
407
+ executionRuntime: "local",
408
+ secretLocation: "device",
409
+ authKind: "oauth",
410
+ availability: "available",
411
+ oauthProfileId: "figma-mcp",
412
+ browserAuth: {
413
+ kind: "browser-oauth",
414
+ flow: "authorization-code-pkce",
415
+ displayName: text("连接官方 MCP", "Connect official MCP"),
416
+ summary: text("需要高级设计内容时连接;授权保存在本机并自动刷新,连接时不读取文件。", "Connect when advanced design content is needed. Authorization is saved locally and refreshed automatically; connecting does not read files."),
417
+ docsUrl: "https://developers.figma.com/docs/figma-mcp-server/remote-server-installation/",
418
+ privacyNote: text("授权页面使用本次验证的 Codex 注册名称;Figma 账号权限仍由官方授权页面决定。", "The authorization page uses the verified Codex registration name; Figma account access is controlled by the official consent page."),
419
+ },
420
+ },
421
+ ],
422
+ fileSelection: { kind: "figma-file", action: "read_design_content", resourceGroup: "files" },
423
+ actionIds: [
424
+ "get_file",
425
+ "read_design_content",
426
+ "get_comments",
427
+ "get_nodes",
428
+ "get_images",
429
+ "get_image_fills",
430
+ ...FIGMA_MCP_ACTION_IDS,
403
431
  ],
404
- fileSelection: { kind: "figma-file", action: "get_file", resourceGroup: "files" },
405
- actionIds: ["get_file", "get_comments", "get_nodes", "get_images", "get_image_fills"],
406
432
  },
407
433
  ];
408
434
  export function getLinkProviderManifest(providerId) {
@@ -0,0 +1,10 @@
1
+ /** Figma-owned routing metadata. Host credentials and MCP lifecycle stay outside this package. */
2
+ export declare const FIGMA_MCP_SERVER_URL = "https://mcp.figma.com/mcp";
3
+ export declare const FIGMA_MCP_SCOPE = "mcp:connect";
4
+ /** User-requested registration name verified against the official endpoint. */
5
+ export declare const FIGMA_MCP_REGISTRATION_CLIENT_NAME = "Codex";
6
+ export declare const FIGMA_MCP_TOOL_IDS: readonly ["get_metadata", "get_design_context", "get_screenshot", "get_variable_defs", "get_motion_context", "get_figjam", "get_code_connect_map", "get_libraries", "search_design_system"];
7
+ /** These actions are always read-only; newly discovered upstream tools are never auto-enabled. */
8
+ export declare const FIGMA_MCP_ACTION_IDS: readonly ["get_metadata", "get_design_context", "get_screenshot", "get_variable_defs", "get_motion_context", "get_figjam", "get_code_connect_map", "get_libraries", "search_design_system", "list_resources", "read_resource"];
9
+ export type FigmaMcpActionId = (typeof FIGMA_MCP_ACTION_IDS)[number];
10
+ export declare function isFigmaMcpAction(action: string): action is FigmaMcpActionId;
package/dist/figma.js ADDED
@@ -0,0 +1,25 @@
1
+ /** Figma-owned routing metadata. Host credentials and MCP lifecycle stay outside this package. */
2
+ export const FIGMA_MCP_SERVER_URL = "https://mcp.figma.com/mcp";
3
+ export const FIGMA_MCP_SCOPE = "mcp:connect";
4
+ /** User-requested registration name verified against the official endpoint. */
5
+ export const FIGMA_MCP_REGISTRATION_CLIENT_NAME = "Codex";
6
+ export const FIGMA_MCP_TOOL_IDS = [
7
+ "get_metadata",
8
+ "get_design_context",
9
+ "get_screenshot",
10
+ "get_variable_defs",
11
+ "get_motion_context",
12
+ "get_figjam",
13
+ "get_code_connect_map",
14
+ "get_libraries",
15
+ "search_design_system",
16
+ ];
17
+ /** These actions are always read-only; newly discovered upstream tools are never auto-enabled. */
18
+ export const FIGMA_MCP_ACTION_IDS = [
19
+ ...FIGMA_MCP_TOOL_IDS,
20
+ "list_resources",
21
+ "read_resource",
22
+ ];
23
+ export function isFigmaMcpAction(action) {
24
+ return FIGMA_MCP_ACTION_IDS.includes(action);
25
+ }
@@ -5,6 +5,8 @@ export type LinkFileSelectionInput = {
5
5
  path: string;
6
6
  ref?: string;
7
7
  label?: string;
8
+ /** The path was picked from a provider listing: keep it byte-exact, never trim it. */
9
+ exact?: boolean;
8
10
  } | {
9
11
  kind: "figma-file";
10
12
  fileUrlOrKey: string;
@@ -13,7 +15,7 @@ export type LinkFileSelectionInput = {
13
15
  /** Provider-owned normalization, shared by Hosts without accepting arbitrary action JSON. */
14
16
  export declare function resolveLinkFileSelection(providerId: string, input: unknown): {
15
17
  providerId: string;
16
- action: "get_file";
18
+ action: "get_file" | "read_design_content";
17
19
  scopeId: string;
18
20
  params: Record<string, string>;
19
21
  resourceId: string;
@@ -1,11 +1,11 @@
1
1
  import { getLinkProviderManifest } from "./catalog.js";
2
- function text(value, field, max) {
2
+ function text(value, field, max, exact = false) {
3
3
  if (typeof value !== "string" ||
4
4
  !value.trim() ||
5
5
  value.length > max ||
6
6
  /[\x00-\x1f\x7f]/.test(value))
7
7
  throw new Error(`Invalid linked file ${field}`);
8
- return value.trim();
8
+ return exact ? value : value.trim();
9
9
  }
10
10
  /** Provider-owned normalization, shared by Hosts without accepting arbitrary action JSON. */
11
11
  export function resolveLinkFileSelection(providerId, input) {
@@ -16,7 +16,7 @@ export function resolveLinkFileSelection(providerId, input) {
16
16
  if (file.kind !== capability.kind)
17
17
  throw new Error("Linked file belongs to another provider");
18
18
  const allowed = capability.kind === "github-file"
19
- ? ["kind", "owner", "repo", "path", "ref", "label"]
19
+ ? ["kind", "owner", "repo", "path", "ref", "label", "exact"]
20
20
  : ["kind", "fileUrlOrKey", "label"];
21
21
  if (Object.keys(file).some((key) => !allowed.includes(key)))
22
22
  throw new Error("Unknown linked file field");
@@ -29,7 +29,10 @@ export function resolveLinkFileSelection(providerId, input) {
29
29
  !/^[a-z0-9_.-]+$/.test(repo) ||
30
30
  [".", ".."].includes(repo))
31
31
  throw new Error("Invalid GitHub repository");
32
- const path = text(file.path, "path", 2_000);
32
+ if (file.exact !== undefined && typeof file.exact !== "boolean")
33
+ throw new Error("Invalid linked file exact flag");
34
+ // Typed paths are trimmed; picked exact paths keep surrounding whitespace (audit HL-01).
35
+ const path = text(file.path, "path", 2_000, file.exact === true);
33
36
  for (const segment of path.split("/")) {
34
37
  let decoded = segment;
35
38
  try {
@@ -72,6 +75,11 @@ export function resolveLinkFileSelection(providerId, input) {
72
75
  scopeId: `${providerId}:${capability.action}`,
73
76
  params,
74
77
  resourceId,
75
- label: label ?? defaultLabel.slice(-512),
78
+ label: label ?? labelTail(defaultLabel, 512),
76
79
  };
77
80
  }
81
+ /** Keep the last `max` UTF-16 units without starting on half a surrogate pair. */
82
+ function labelTail(value, max) {
83
+ const tail = value.slice(-max);
84
+ return /^[\uDC00-\uDFFF]/.test(tail) ? tail.slice(1) : tail;
85
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  export { LINK_PROVIDER_MANIFESTS, getLinkProviderManifest, localizeLinkText } from "./catalog.js";
2
2
  export { resolveLinkFileSelection } from "./file-selection.js";
3
+ export { LINK_OPERATION_CONTRACTS, getLinkOperationContract } from "./operations.js";
4
+ export { LINK_CONNECTION_RECIPES, getLinkConnectionRecipe, resolveLinkRecipeId, linkConnectionMetadata, } from "./recipes.js";
5
+ export { LinkOperationError, type LinkValueSchema, type LinkResourcePolicy, type LinkOperationContract, type LinkConnectionRecipe, type LinkRecipeId, type LinkOperationRequest, type LinkOperationAuthorization, } from "./operation-types.js";
6
+ export { validateLinkOperationValue, validateLinkOperationInput, snapshotLinkOperationInput, assertLinkOperationResult, filterLinkOperationResult, assertLinkOperationAuthorized, } from "./operation-validation.js";
7
+ export { FIGMA_MCP_SERVER_URL, FIGMA_MCP_SCOPE, FIGMA_MCP_REGISTRATION_CLIENT_NAME, FIGMA_MCP_TOOL_IDS, FIGMA_MCP_ACTION_IDS, isFigmaMcpAction, type FigmaMcpActionId, } from "./figma.js";
3
8
  export type { LinkFileSelectionInput } from "./file-selection.js";
4
9
  export type { LinkAuthGuide, LinkAuthKind, LinkConnectionMethodManifest, LinkCliSessionAuth, LinkExecutionRuntime, LinkLocalizedText, LinkMethodAvailability, LinkPermissionGuide, LinkPermissionLevel, LinkProviderAccent, LinkProviderCategory, LinkProviderIcon, LinkProviderManifest, LinkSecretLocation, } from "./types.js";
5
- export type { LinkProviderView, LinkAuthMode, LinkAuthorization, LinkAuthorizationInput, LinkAuthorizationResponse, LinkAuthorizationStep, LinkCredentialFieldView, LinkResourceGroupView, LinkConnectionInput, LinkErrorCode, LinkSnapshot, MaskedLinkConnection, TokenConnectionInput, } from "./management-types.js";
10
+ export type { LinkProviderView, LinkAuthMode, LinkAuthorization, LinkAuthorizationFailure, LinkAuthorizationInput, LinkAuthorizationResponse, LinkAuthorizationStep, LinkCredentialFieldView, LinkResourceGroupView, LinkConnectionInput, LinkErrorCode, LinkSnapshot, MaskedLinkConnection, LinkConnectionState, TokenConnectionInput, } from "./management-types.js";
package/dist/index.js CHANGED
@@ -1,2 +1,7 @@
1
1
  export { LINK_PROVIDER_MANIFESTS, getLinkProviderManifest, localizeLinkText } from "./catalog.js";
2
2
  export { resolveLinkFileSelection } from "./file-selection.js";
3
+ export { LINK_OPERATION_CONTRACTS, getLinkOperationContract } from "./operations.js";
4
+ export { LINK_CONNECTION_RECIPES, getLinkConnectionRecipe, resolveLinkRecipeId, linkConnectionMetadata, } from "./recipes.js";
5
+ export { LinkOperationError, } from "./operation-types.js";
6
+ export { validateLinkOperationValue, validateLinkOperationInput, snapshotLinkOperationInput, assertLinkOperationResult, filterLinkOperationResult, assertLinkOperationAuthorized, } from "./operation-validation.js";
7
+ export { FIGMA_MCP_SERVER_URL, FIGMA_MCP_SCOPE, FIGMA_MCP_REGISTRATION_CLIENT_NAME, FIGMA_MCP_TOOL_IDS, FIGMA_MCP_ACTION_IDS, isFigmaMcpAction, } from "./figma.js";
@@ -102,13 +102,30 @@ export interface LinkAuthorizationResponse {
102
102
  operation: "submit" | "detect-session" | "login-session" | "bind-session" | "confirm" | "refresh-qr";
103
103
  input?: Record<string, string | string[] | boolean>;
104
104
  }
105
+ export interface LinkConnectionState {
106
+ authorization: {
107
+ state: "authorized" | "reauthorization_required" | "unknown";
108
+ };
109
+ compatibility: {
110
+ state: "unknown" | "compatible" | "upgrade_required" | "unsupported";
111
+ };
112
+ /** A live operation observation, never inferred from credential verification or refresh. */
113
+ observation: {
114
+ state: "unchecked" | "succeeded" | "failed";
115
+ checkedAt?: string;
116
+ action?: string;
117
+ resource?: string;
118
+ };
119
+ }
105
120
  export interface MaskedLinkConnection {
106
121
  id: string;
107
122
  providerId: string;
108
123
  methodId: string;
109
124
  label: string;
110
125
  runtime: "local" | "server";
111
- authSource: "manual-token" | "cli-session" | "browser-oauth" | "remote-link";
126
+ authSource: "manual-token" | "cli-session" | "browser-oauth" | "mcp-oauth" | "remote-link";
127
+ recipeId?: import("./operation-types.js").LinkRecipeId;
128
+ connectionState?: LinkConnectionState;
112
129
  status: "connected" | "expired" | "invalid" | "unavailable";
113
130
  account?: {
114
131
  id?: string;
@@ -145,6 +162,14 @@ export interface LinkSnapshot {
145
162
  remoteServer?: {
146
163
  issuer: string;
147
164
  };
165
+ /** Discovery health is independent of saved account/grant state. */
166
+ remoteCatalog?: {
167
+ state: "unconfigured" | "unknown" | "ready" | "unavailable" | "stale";
168
+ checkedAt?: string;
169
+ lastSuccessAt?: string;
170
+ errorCode?: "network_error" | "http_error" | "invalid_response" | "response_too_large";
171
+ httpStatus?: number;
172
+ };
148
173
  /** Count only; retired secrets never leave the Host. Retried after restart with backoff. */
149
174
  remoteCleanupPending?: number;
150
175
  revision: string;
@@ -188,4 +213,13 @@ export interface LinkAuthorization {
188
213
  previousGrantRevocationPending?: boolean;
189
214
  connection?: MaskedLinkConnection;
190
215
  errorCode?: LinkErrorCode;
216
+ failure?: LinkAuthorizationFailure;
217
+ }
218
+ /** Fixed Host diagnostics only: never upstream messages, response bodies, or credentials. */
219
+ export interface LinkAuthorizationFailure {
220
+ code: "invalid_request" | "access_denied" | "timeout" | "network_error" | "server_error" | "protocol_error";
221
+ stage: "validation" | "authorization" | "discovery_registration" | "authorization_callback" | "token_exchange" | "credential_verification";
222
+ verificationStage?: "credential_binding" | "mcp_initialize" | "tool_discovery" | "account_verification" | "capability_verification";
223
+ reason?: "binding_mismatch" | "cancelled" | "network_error" | "http_error" | "protocol_error" | "tool_unavailable" | "invalid_pagination" | "response_too_large" | "account_tool_error" | "account_identity_unavailable" | "no_read_capabilities";
224
+ httpStatus?: number;
191
225
  }
@@ -0,0 +1,103 @@
1
+ /** Portable, reviewed operation contracts. These definitions never contain credentials or code. */
2
+ export interface LinkValueSchema {
3
+ readonly type?: "object" | "array" | "string" | "number" | "integer" | "boolean" | "null";
4
+ readonly properties?: Readonly<Record<string, LinkValueSchema>>;
5
+ readonly required?: readonly string[];
6
+ readonly additionalProperties?: boolean;
7
+ readonly items?: LinkValueSchema;
8
+ readonly minLength?: number;
9
+ readonly maxLength?: number;
10
+ readonly minimum?: number;
11
+ readonly maximum?: number;
12
+ readonly minItems?: number;
13
+ readonly maxItems?: number;
14
+ readonly enum?: readonly (string | number | boolean | null)[];
15
+ /** Only reviewed, bounded patterns from the built-in catalog are accepted. */
16
+ readonly pattern?: string;
17
+ readonly anyOf?: readonly LinkValueSchema[];
18
+ readonly oneOf?: readonly LinkValueSchema[];
19
+ }
20
+ export interface LinkResourcePolicy {
21
+ /** Always declared, including list operations whose results need grant filtering. */
22
+ readonly source: "input" | "grant-filtered" | "account";
23
+ readonly group: string;
24
+ /** A selected-resource listing must remove every item without a provable authorized identity. */
25
+ readonly resultFilter?: {
26
+ readonly collection: string;
27
+ readonly identityPath: readonly string[];
28
+ readonly lowercase?: boolean;
29
+ readonly stripHyphens?: boolean;
30
+ };
31
+ readonly selector?: {
32
+ readonly fields: readonly string[];
33
+ readonly kind: "joined" | "identifier-or-url" | "slack-permalink";
34
+ readonly separator?: string;
35
+ readonly lowercase?: boolean;
36
+ readonly stripHyphens?: boolean;
37
+ readonly urlHosts?: readonly string[];
38
+ readonly urlPathIndex?: number;
39
+ };
40
+ }
41
+ export interface LinkOperationContract {
42
+ readonly operationId: string;
43
+ readonly providerId: string;
44
+ /** Unqualified v1 wire name: lower-case letters and underscores only. */
45
+ readonly action: string;
46
+ readonly effect: "read" | "write";
47
+ readonly executors: readonly ("api" | "mcp" | "cli")[];
48
+ readonly inputSchema: LinkValueSchema;
49
+ readonly outputSchema: LinkValueSchema;
50
+ readonly resourcePolicy: LinkResourcePolicy;
51
+ readonly content?: {
52
+ readonly kind: string;
53
+ readonly coverage: "summary" | "requested-subtree" | "selected-resource" | "page";
54
+ };
55
+ readonly defaults: Readonly<Record<string, string | number | boolean>>;
56
+ readonly limits: {
57
+ readonly inputBytes: number;
58
+ readonly responseBytes: number;
59
+ readonly inputDepth: number;
60
+ readonly responseDepth: number;
61
+ };
62
+ readonly repeatability: "read-only" | "verified-write-once";
63
+ /** Existing adapters perform these canonicalizations after structural validation. */
64
+ readonly normalization: readonly string[];
65
+ }
66
+ export type LinkRecipeId = "api-token" | "api-oauth" | "cli-session" | "broker-oauth" | "mcp-oauth";
67
+ export interface LinkConnectionRecipe {
68
+ readonly id: LinkRecipeId;
69
+ readonly auth: "static-secret" | "oauth2" | "external-session";
70
+ readonly executor: "api" | "mcp" | "cli";
71
+ readonly route: "direct" | "brokered";
72
+ readonly credentialOwner: "current-host" | "broker" | "external-session-owner";
73
+ readonly executorOwner: "current-host" | "broker";
74
+ }
75
+ export interface LinkOperationRequest {
76
+ readonly connectionId: string;
77
+ readonly providerId: string;
78
+ readonly action: string;
79
+ readonly params: Readonly<Record<string, unknown>>;
80
+ }
81
+ export interface LinkOperationAuthorization {
82
+ readonly connectionId: string;
83
+ readonly providerId: string;
84
+ readonly accountId: string;
85
+ readonly recipeId: LinkRecipeId;
86
+ readonly authorityRevision: string;
87
+ readonly capabilityIds: readonly string[];
88
+ readonly resources: {
89
+ readonly kind: "upstream-scoped";
90
+ } | {
91
+ readonly kind: "selected-resources";
92
+ readonly groups: readonly {
93
+ readonly id: string;
94
+ readonly items: readonly {
95
+ readonly id: string;
96
+ }[];
97
+ }[];
98
+ };
99
+ }
100
+ export declare class LinkOperationError extends Error {
101
+ readonly code: "invalid_input" | "invalid_result" | "unsupported_contract" | "permission_denied" | "resource_not_granted" | "contract_changed";
102
+ constructor(code: "invalid_input" | "invalid_result" | "unsupported_contract" | "permission_denied" | "resource_not_granted" | "contract_changed", message: string);
103
+ }
@@ -0,0 +1,8 @@
1
+ export class LinkOperationError extends Error {
2
+ code;
3
+ constructor(code, message) {
4
+ super(message);
5
+ this.code = code;
6
+ this.name = "LinkOperationError";
7
+ }
8
+ }
@@ -0,0 +1,9 @@
1
+ import { type LinkValueSchema, type LinkOperationAuthorization, type LinkOperationContract } from "./operation-types.js";
2
+ export declare function validateLinkOperationValue(schema: LinkValueSchema, value: unknown): boolean;
3
+ export declare function validateLinkOperationInput(contract: LinkOperationContract, input: unknown): asserts input is Record<string, unknown>;
4
+ export declare function snapshotLinkOperationInput(contract: LinkOperationContract, input: unknown): Record<string, unknown>;
5
+ export declare function assertLinkOperationResult(contract: LinkOperationContract, result: unknown): void;
6
+ /** Applies equally to direct API, CLI, MCP and broker adapters. Missing identity fails closed. */
7
+ export declare function filterLinkOperationResult(contract: LinkOperationContract, result: unknown, authorization: LinkOperationAuthorization): unknown;
8
+ /** Content traits never decide whether an operation needs authorization. */
9
+ export declare function assertLinkOperationAuthorized(contract: LinkOperationContract, params: Record<string, unknown>, authorization: LinkOperationAuthorization): void;
@@ -0,0 +1,216 @@
1
+ import { LinkOperationError, } from "./operation-types.js";
2
+ import { getLinkConnectionRecipe } from "./recipes.js";
3
+ /** Copy JSON data without invoking getters, toJSON, inherited code or mutable caller objects. */
4
+ function snapshotJson(value, ancestors, depth, maxDepth, forbidKeys) {
5
+ if (depth > maxDepth)
6
+ throw new Error("JSON nesting limit");
7
+ if (value === null || typeof value === "string" || typeof value === "boolean")
8
+ return value;
9
+ if (typeof value === "number" && Number.isFinite(value))
10
+ return value;
11
+ if (!value || typeof value !== "object" || ancestors.has(value))
12
+ throw new Error("Invalid JSON data");
13
+ const prototype = Object.getPrototypeOf(value);
14
+ if ((!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) ||
15
+ Object.getOwnPropertySymbols(value).length)
16
+ throw new Error("Invalid JSON object");
17
+ ancestors.add(value);
18
+ try {
19
+ const descriptors = Object.getOwnPropertyDescriptors(value);
20
+ if (Array.isArray(value)) {
21
+ if (value.length > 100_000)
22
+ throw new Error("JSON array limit");
23
+ return Array.from({ length: value.length }, (_, index) => {
24
+ const descriptor = descriptors[String(index)];
25
+ if (descriptor && !Object.hasOwn(descriptor, "value"))
26
+ throw new Error("JSON accessor");
27
+ return descriptor?.value === undefined
28
+ ? null
29
+ : snapshotJson(descriptor.value, ancestors, depth + 1, maxDepth, forbidKeys);
30
+ });
31
+ }
32
+ const result = Object.create(null);
33
+ for (const [key, descriptor] of Object.entries(descriptors)) {
34
+ if (!descriptor.enumerable)
35
+ continue;
36
+ if (!Object.hasOwn(descriptor, "value") ||
37
+ (forbidKeys && ["__proto__", "constructor", "prototype"].includes(key)))
38
+ throw new Error("Invalid JSON property");
39
+ if (descriptor.value !== undefined)
40
+ result[key] = snapshotJson(descriptor.value, ancestors, depth + 1, maxDepth, forbidKeys);
41
+ }
42
+ return result;
43
+ }
44
+ finally {
45
+ ancestors.delete(value);
46
+ }
47
+ }
48
+ function matches(schema, value, depth = 0) {
49
+ if (depth > 20)
50
+ return false;
51
+ if (schema.enum && !schema.enum.includes(value))
52
+ return false;
53
+ if (schema.anyOf && !schema.anyOf.some((item) => matches(item, value, depth + 1)))
54
+ return false;
55
+ if (schema.oneOf && schema.oneOf.filter((item) => matches(item, value, depth + 1)).length !== 1)
56
+ return false;
57
+ if (schema.type === "null")
58
+ return value === null;
59
+ if (schema.type === "boolean")
60
+ return typeof value === "boolean";
61
+ if (schema.type === "string")
62
+ return (typeof value === "string" &&
63
+ value.length >= (schema.minLength ?? 0) &&
64
+ value.length <= (schema.maxLength ?? Infinity) &&
65
+ (!schema.pattern || new RegExp(schema.pattern).test(value)));
66
+ if (schema.type === "number" || schema.type === "integer")
67
+ return (typeof value === "number" &&
68
+ Number.isFinite(value) &&
69
+ (schema.type !== "integer" || Number.isSafeInteger(value)) &&
70
+ value >= (schema.minimum ?? -Infinity) &&
71
+ value <= (schema.maximum ?? Infinity));
72
+ if (schema.type === "array")
73
+ return (Array.isArray(value) &&
74
+ value.length >= (schema.minItems ?? 0) &&
75
+ value.length <= (schema.maxItems ?? Infinity) &&
76
+ (!schema.items || value.every((item) => matches(schema.items, item, depth + 1))));
77
+ if (schema.type === "object" || schema.required || schema.properties) {
78
+ if (!value || typeof value !== "object" || Array.isArray(value))
79
+ return false;
80
+ const record = value;
81
+ if (schema.required?.some((key) => !Object.hasOwn(record, key)))
82
+ return false;
83
+ return Object.entries(record).every(([key, item]) => {
84
+ if (["__proto__", "prototype", "constructor"].includes(key))
85
+ return false;
86
+ const property = schema.properties && Object.hasOwn(schema.properties, key)
87
+ ? schema.properties[key]
88
+ : undefined;
89
+ return property ? matches(property, item, depth + 1) : schema.additionalProperties !== false;
90
+ });
91
+ }
92
+ return true;
93
+ }
94
+ export function validateLinkOperationValue(schema, value) {
95
+ return matches(schema, value);
96
+ }
97
+ export function validateLinkOperationInput(contract, input) {
98
+ snapshotLinkOperationInput(contract, input);
99
+ }
100
+ export function snapshotLinkOperationInput(contract, input) {
101
+ let detached;
102
+ let size = Infinity;
103
+ try {
104
+ detached = snapshotJson(input, new Set(), 0, contract.limits.inputDepth, true);
105
+ size = new TextEncoder().encode(JSON.stringify(detached)).length;
106
+ }
107
+ catch {
108
+ /* Reject cyclic/non-JSON inputs. */
109
+ }
110
+ if (size > contract.limits.inputBytes || !matches(contract.inputSchema, detached))
111
+ throw new LinkOperationError("invalid_input", `Invalid parameters for ${contract.operationId}.`);
112
+ return detached;
113
+ }
114
+ export function assertLinkOperationResult(contract, result) {
115
+ let detached;
116
+ let size = Infinity;
117
+ try {
118
+ detached = snapshotJson(result, new Set(), 0, contract.limits.responseDepth, false);
119
+ size = new TextEncoder().encode(JSON.stringify(detached)).length;
120
+ }
121
+ catch {
122
+ /* Fail closed. */
123
+ }
124
+ if (size > contract.limits.responseBytes || !matches(contract.outputSchema, detached))
125
+ throw new LinkOperationError("invalid_result", `Invalid result for ${contract.operationId}.`);
126
+ }
127
+ /** Applies equally to direct API, CLI, MCP and broker adapters. Missing identity fails closed. */
128
+ export function filterLinkOperationResult(contract, result, authorization) {
129
+ if (authorization.resources.kind === "upstream-scoped" ||
130
+ contract.resourcePolicy.source !== "grant-filtered")
131
+ return result;
132
+ const filter = contract.resourcePolicy.resultFilter;
133
+ const group = authorization.resources.groups.find((item) => item.id === contract.resourcePolicy.group);
134
+ if (!filter || !group || !result || typeof result !== "object" || Array.isArray(result))
135
+ throw new LinkOperationError("invalid_result", "The operation cannot prove its result resource scope.");
136
+ const values = result[filter.collection];
137
+ if (!Array.isArray(values))
138
+ throw new LinkOperationError("invalid_result", "The operation returned an invalid resource listing.");
139
+ const normalize = (value) => {
140
+ const normalized = filter.stripHyphens ? value.replaceAll("-", "") : value;
141
+ return filter.lowercase ? normalized.toLowerCase() : normalized;
142
+ };
143
+ const allowed = new Set(group.items.map((item) => normalize(item.id)));
144
+ return {
145
+ ...result,
146
+ [filter.collection]: values.filter((item) => {
147
+ let value = item;
148
+ for (const key of filter.identityPath)
149
+ value =
150
+ value && typeof value === "object" ? value[key] : undefined;
151
+ return ((typeof value === "string" || typeof value === "number") &&
152
+ allowed.has(normalize(String(value))));
153
+ }),
154
+ };
155
+ }
156
+ /** Content traits never decide whether an operation needs authorization. */
157
+ export function assertLinkOperationAuthorized(contract, params, authorization) {
158
+ const recipe = getLinkConnectionRecipe(authorization.recipeId);
159
+ if (!recipe ||
160
+ authorization.providerId !== contract.providerId ||
161
+ !authorization.connectionId ||
162
+ !authorization.accountId ||
163
+ !authorization.authorityRevision ||
164
+ !contract.executors.includes(recipe.executor) ||
165
+ !authorization.capabilityIds.includes(contract.operationId))
166
+ throw new LinkOperationError("permission_denied", "The selected connection does not authorize this operation.");
167
+ if (contract.effect === "write" && recipe.executor === "cli")
168
+ throw new LinkOperationError("permission_denied", "CLI connections do not support verified writes.");
169
+ if (authorization.resources.kind === "upstream-scoped") {
170
+ if (recipe.route === "brokered")
171
+ throw new LinkOperationError("permission_denied", "Broker operations require an explicit resource grant.");
172
+ return;
173
+ }
174
+ const group = authorization.resources.groups.find((item) => item.id === contract.resourcePolicy.group);
175
+ if (!group)
176
+ throw new LinkOperationError("resource_not_granted", "The selected resource group is not authorized.");
177
+ if (contract.resourcePolicy.source !== "input")
178
+ return; // The executor must filter listing results to this group.
179
+ const selector = contract.resourcePolicy.selector;
180
+ if (!selector)
181
+ throw new LinkOperationError("resource_not_granted", "The operation has no reviewed resource selector.");
182
+ let selected;
183
+ if (selector.kind === "joined") {
184
+ const parts = selector.fields.map((field) => params[field]);
185
+ if (parts.some((part) => typeof part !== "string" || !part))
186
+ throw new LinkOperationError("invalid_input", "An explicit resource target is required.");
187
+ selected = parts.join(selector.separator ?? "/");
188
+ }
189
+ else {
190
+ const value = selector.fields.map((field) => params[field]).find((item) => item !== undefined);
191
+ if (typeof value !== "string" || !value)
192
+ throw new LinkOperationError("invalid_input", "An explicit resource target is required.");
193
+ selected = value;
194
+ if (selector.kind === "slack-permalink" || /^https?:\/\//i.test(value)) {
195
+ let url;
196
+ try {
197
+ url = new URL(value);
198
+ }
199
+ catch {
200
+ throw new LinkOperationError("invalid_input", "Invalid resource URL.");
201
+ }
202
+ const allowedHost = selector.kind === "slack-permalink"
203
+ ? /^[a-z0-9-]+\.slack\.com$/.test(url.hostname)
204
+ : selector.urlHosts?.includes(url.hostname);
205
+ if (url.protocol !== "https:" || url.username || url.password || url.port || !allowedHost)
206
+ throw new LinkOperationError("invalid_input", "Invalid resource URL.");
207
+ selected = url.pathname.split("/").filter(Boolean)[selector.urlPathIndex ?? 1] ?? "";
208
+ }
209
+ }
210
+ const normalize = (value) => {
211
+ const normalized = selector.stripHyphens ? value.replaceAll("-", "") : value;
212
+ return selector.lowercase ? normalized.toLowerCase() : normalized;
213
+ };
214
+ if (!group.items.some((item) => normalize(item.id) === normalize(selected)))
215
+ throw new LinkOperationError("resource_not_granted", "The resource is outside this connection's grant.");
216
+ }
@@ -0,0 +1,4 @@
1
+ import type { LinkOperationContract } from "./operation-types.js";
2
+ /** Reviewed v1 contracts. Discovery can select these operations, never add executable code. */
3
+ export declare const LINK_OPERATION_CONTRACTS: readonly LinkOperationContract[];
4
+ export declare function getLinkOperationContract(provider: string, action: string): LinkOperationContract | undefined;
@@ -0,0 +1,312 @@
1
+ import { FIGMA_MCP_ACTION_IDS } from "./figma.js";
2
+ const text = (maxLength = 300, minLength = 1) => ({
3
+ type: "string",
4
+ minLength,
5
+ maxLength,
6
+ });
7
+ const flag = { type: "boolean" };
8
+ // Existing v1 adapters accept numeric strings and clamp pagination. Keep that behavior.
9
+ const integer = { anyOf: [{ type: "integer", minimum: 1 }, text(100)] };
10
+ const number = { anyOf: [{ type: "number" }, text(100)] };
11
+ const list = (items = { type: "object" }, maxItems = 100) => ({
12
+ type: "array",
13
+ items,
14
+ maxItems,
15
+ });
16
+ const object = (properties, required = []) => ({ type: "object", properties, required, additionalProperties: false });
17
+ const output = (properties, required = []) => ({ type: "object", properties, required, additionalProperties: true });
18
+ const listOutput = (key, max = 100) => output({ [key]: list({ type: "object" }, max) }, [key]);
19
+ const groups = {
20
+ github: "repositories",
21
+ gitlab: "projects",
22
+ figma: "files",
23
+ notion: "pages",
24
+ linear: "teams",
25
+ slack: "channels",
26
+ sentry: "organizations",
27
+ airtable: "bases",
28
+ todoist: "projects",
29
+ vercel: "projects",
30
+ };
31
+ const inputResource = (group, fields, options = {}) => ({
32
+ source: "input",
33
+ group,
34
+ selector: { kind: "joined", fields, ...options },
35
+ });
36
+ const filtered = (provider, action) => {
37
+ const collection = provider === "notion" ? "results" : action.replace(/^list_/, "");
38
+ const identityPath = provider === "github"
39
+ ? ["full_name"]
40
+ : provider === "sentry"
41
+ ? ["slug"]
42
+ : provider === "gitlab" && action === "list_issues"
43
+ ? ["project_id"]
44
+ : provider === "todoist" && action === "list_tasks"
45
+ ? ["project_id"]
46
+ : provider === "linear" && action === "list_issues"
47
+ ? ["team", "id"]
48
+ : provider === "vercel" && action === "list_deployments"
49
+ ? ["projectId"]
50
+ : ["id"];
51
+ return {
52
+ source: "grant-filtered",
53
+ group: groups[provider],
54
+ resultFilter: {
55
+ collection,
56
+ identityPath,
57
+ ...(provider === "github" || provider === "notion" || provider === "linear"
58
+ ? { lowercase: true }
59
+ : {}),
60
+ ...(provider === "notion" ? { stripHyphens: true } : {}),
61
+ },
62
+ };
63
+ };
64
+ const repo = { owner: text(100), repo: text(100) };
65
+ const file = { file_url_or_key: text(4096), file_key: text(200) };
66
+ const fileInput = (properties = {}, required = []) => ({
67
+ ...object({ ...file, ...properties }, required),
68
+ oneOf: [{ required: ["file_url_or_key"] }, { required: ["file_key"] }],
69
+ });
70
+ const fileResource = inputResource("files", ["file_url_or_key", "file_key"], {
71
+ kind: "identifier-or-url",
72
+ urlHosts: ["figma.com", "www.figma.com"],
73
+ urlPathIndex: 1,
74
+ });
75
+ const nodeList = { anyOf: [text(20_000), list(text(200), 50)] };
76
+ function operation(provider, action, inputSchema, outputSchema, resourcePolicy = filtered(provider, action), options = {}) {
77
+ const effect = options.effect ?? "read";
78
+ return {
79
+ operationId: `${provider}.${action}`,
80
+ providerId: provider,
81
+ action,
82
+ effect,
83
+ executors: options.executors ??
84
+ (["github", "gitlab", "notion", "todoist", "vercel"].includes(provider)
85
+ ? ["api", "cli"]
86
+ : ["api"]),
87
+ inputSchema,
88
+ outputSchema,
89
+ resourcePolicy,
90
+ defaults: options.defaults ?? {},
91
+ limits: {
92
+ inputBytes: action === "create_issue" ? 256 * 1024 : 65_536,
93
+ responseBytes: provider === "figma" ? 8 * 1024 * 1024 : 1024 * 1024,
94
+ inputDepth: 20,
95
+ responseDepth: 256,
96
+ },
97
+ repeatability: effect === "write" ? "verified-write-once" : "read-only",
98
+ normalization: options.normalization ?? [],
99
+ ...(options.content ? { content: options.content } : {}),
100
+ };
101
+ }
102
+ const gh = (action, properties = {}, required = [], result = output({}), options = {}) => operation("github", action, object({ ...repo, ...properties }, ["owner", "repo", ...required]), result, inputResource("repositories", ["owner", "repo"], { lowercase: true }), options);
103
+ const pagination = {
104
+ defaults: { limit: 30 },
105
+ normalization: ["limit: positive number or numeric string; clamp to 100"],
106
+ };
107
+ const sourcePagination = {
108
+ defaults: { page: 1, limit: 30 },
109
+ normalization: [
110
+ "page: positive number or numeric string; clamp to 100",
111
+ "limit: positive integer <= 100",
112
+ "sha: full hexadecimal commit id",
113
+ ],
114
+ };
115
+ const content = { kind: "application/json", coverage: "selected-resource" };
116
+ /** Reviewed v1 contracts. Discovery can select these operations, never add executable code. */
117
+ export const LINK_OPERATION_CONTRACTS = [
118
+ operation("github", "list_repositories", object({ limit: integer }), listOutput("repositories"), undefined, pagination),
119
+ gh("get_repository", {}, [], output({ id: {}, full_name: text(), private: flag, archived: flag, html_url: text(4096) })),
120
+ gh("get_starred", {}, [], output({ starred: flag }, ["starred"])),
121
+ gh("set_starred", { starred: flag }, ["starred"], output({ acknowledged: flag }, ["acknowledged"]), { effect: "write", executors: ["api"] }),
122
+ gh("get_readme", {}, [], output({ content: text(262_144, 0), truncated: flag }, ["content", "truncated"]), { content: { kind: "text/markdown", coverage: "selected-resource" } }),
123
+ gh("get_file", { path: text(2000), ref: text(300) }, ["path"], output({ content: text(262_144, 0), truncated: flag }, ["content", "truncated"]), { content: { kind: "text/plain", coverage: "selected-resource" } }),
124
+ gh("list_issues", { limit: integer, page: integer, state: { enum: ["open", "closed", "all"] } }, [], listOutput("issues"), pagination),
125
+ gh("list_pull_requests", { limit: integer, page: integer }, [], listOutput("pull_requests"), pagination),
126
+ gh("get_issue", { issue_number: integer }, ["issue_number"]),
127
+ gh("get_pull_request", { pull_number: integer }, ["pull_number"]),
128
+ gh("list_pull_request_files", { pull_number: integer, page: integer, limit: integer }, ["pull_number"], output({ files: list() }), sourcePagination),
129
+ gh("list_pull_request_commits", { pull_number: integer, page: integer, limit: integer }, ["pull_number"], output({ commits: list() }), sourcePagination),
130
+ gh("get_commit", { sha: text(40), page: integer, limit: integer }, ["sha"], output({}), sourcePagination),
131
+ gh("create_issue", { title: text(256), body: text(20_000, 0) }, ["title"], output({
132
+ number: { type: "integer" },
133
+ title: text(256),
134
+ state: text(100),
135
+ html_url: text(4096),
136
+ created_at: text(100),
137
+ }), {
138
+ effect: "write",
139
+ executors: ["api"],
140
+ normalization: [
141
+ "trim title and body; owner/repo identify the authorized parent repository",
142
+ "requires independent list_issues/get_issue verification",
143
+ ],
144
+ }),
145
+ gh("update_issue", { issue_number: { type: "integer", minimum: 1 }, state: { enum: ["open", "closed"] } }, ["issue_number", "state"], output({ acknowledged: flag }, ["acknowledged"]), {
146
+ effect: "write",
147
+ executors: ["api"],
148
+ normalization: ["non-PR issue only; verify immutable repository and issue IDs independently"],
149
+ }),
150
+ operation("gitlab", "list_projects", object({ limit: integer }), listOutput("projects"), undefined, pagination),
151
+ operation("gitlab", "list_issues", object({ limit: integer }), listOutput("issues"), undefined, pagination),
152
+ operation("figma", "get_file", fileInput(), output({ pages: list({}, 100), truncated: flag }, ["pages", "truncated"]), fileResource, {
153
+ defaults: { depth: 1 },
154
+ content: { kind: "application/vnd.figma.summary+json", coverage: "summary" },
155
+ normalization: [
156
+ "summary only; no document; at most 100 pages; reject all depth/full/ids parameters",
157
+ ],
158
+ }),
159
+ operation("figma", "read_design_content", fileInput({
160
+ ids: nodeList,
161
+ node_ids: nodeList,
162
+ version: text(200),
163
+ depth: integer,
164
+ geometry: { enum: ["paths"] },
165
+ plugin_data: nodeList,
166
+ branch_data: flag,
167
+ full: flag,
168
+ }), output({
169
+ document: { type: "object" },
170
+ pages: { type: "array", items: { type: "object" } },
171
+ truncated: flag,
172
+ }, ["document"]), fileResource, {
173
+ defaults: { depth: 2, full: false },
174
+ content: { kind: "application/vnd.figma.document+json", coverage: "requested-subtree" },
175
+ normalization: [
176
+ "reject conflicting file aliases, ids aliases and full/depth",
177
+ "node-id in URL is an input selector; depth 1..100; full:true must be explicit",
178
+ "preserve nested node properties; no implicit complete-file read",
179
+ ],
180
+ }),
181
+ operation("figma", "get_nodes", fileInput({
182
+ ids: nodeList,
183
+ node_ids: nodeList,
184
+ version: text(200),
185
+ depth: integer,
186
+ geometry: { enum: ["paths"] },
187
+ plugin_data: nodeList,
188
+ }), {
189
+ ...output({ nodes: { type: "object" }, truncated: flag }),
190
+ anyOf: [{ required: ["nodes"] }, { required: ["err"] }],
191
+ }, fileResource, {
192
+ content,
193
+ normalization: [
194
+ "require explicit ids or URL node-id; 1..50 node IDs; reject conflicting aliases; depth 1..100",
195
+ ],
196
+ }),
197
+ operation("figma", "get_images", fileInput({
198
+ ids: nodeList,
199
+ node_ids: nodeList,
200
+ version: text(200),
201
+ scale: number,
202
+ format: { enum: ["png", "jpg", "svg", "pdf"] },
203
+ svg_outline_text: flag,
204
+ svg_include_id: flag,
205
+ svg_include_node_id: flag,
206
+ svg_simplify_stroke: flag,
207
+ contents_only: flag,
208
+ use_absolute_bounds: flag,
209
+ }), {
210
+ ...output({ images: { type: "object" } }),
211
+ anyOf: [{ required: ["images"] }, { required: ["err"] }],
212
+ }, fileResource, {
213
+ normalization: [
214
+ "require explicit ids or URL node-id; scale 0.01..4; reject conflicting aliases",
215
+ ],
216
+ }),
217
+ operation("figma", "get_image_fills", fileInput(), {
218
+ ...output({
219
+ meta: { type: "object", properties: { images: { type: "object" } }, required: ["images"] },
220
+ }),
221
+ anyOf: [{ required: ["meta"] }, { required: ["err"] }],
222
+ }, fileResource),
223
+ operation("figma", "get_comments", fileInput(), output({ comments: { type: "array", items: { type: "object" } } }), fileResource),
224
+ ...FIGMA_MCP_ACTION_IDS.map((action) => operation("figma", action, fileInput({
225
+ file_type: { enum: ["design", "figjam", "make", "slides"] },
226
+ node_id: text(200),
227
+ client_languages: text(200, 0),
228
+ client_frameworks: text(200, 0),
229
+ force_code: flag,
230
+ disable_code_connect: flag,
231
+ recursive: flag,
232
+ queries: list(text(1000), 20),
233
+ ...(action === "read_resource" ? { uri: text(4096) } : {}),
234
+ ...(action === "list_resources" ? { cursor: text(4096, 0) } : {}),
235
+ }, action === "read_resource" ? ["uri"] : []), output({}), fileResource, {
236
+ executors: ["mcp"],
237
+ content: {
238
+ kind: "application/vnd.figma.mcp+json",
239
+ coverage: action === "list_resources" ? "page" : "requested-subtree",
240
+ },
241
+ normalization: [
242
+ "official MCP origin and issuer/resource binding; exact reviewed tool allowlist",
243
+ "detailed reads require node_id; Make uses discovery then selected file URI",
244
+ "resource URI must belong to selected file and be admitted on this connection",
245
+ ],
246
+ })),
247
+ operation("notion", "search", object({ query: text(200, 0), limit: integer }), listOutput("results"), undefined, pagination),
248
+ operation("notion", "get_page", object({ page_id: text(100) }, ["page_id"]), output({}), inputResource("pages", ["page_id"], { lowercase: true, stripHyphens: true })),
249
+ operation("linear", "list_issues", object({ limit: integer }), listOutput("issues", 50), undefined, {
250
+ defaults: { limit: 30 },
251
+ normalization: ["limit: positive number or numeric string; clamp to 50"],
252
+ }),
253
+ operation("linear", "list_teams", object({ limit: integer }), listOutput("teams", 50)),
254
+ operation("slack", "list_channels", object({ limit: integer }), listOutput("channels", 200), undefined, {
255
+ defaults: { limit: 50 },
256
+ normalization: ["limit: positive number or numeric string; clamp to 200"],
257
+ }),
258
+ operation("slack", "get_channel_history", object({ channel: text(100), limit: integer }, ["channel"]), listOutput("messages"), inputResource("channels", ["channel"]), {
259
+ defaults: { limit: 15 },
260
+ normalization: ["limit: positive number or numeric string; clamp to 15"],
261
+ }),
262
+ operation("slack", "get_message", object({ permalink: text(4096) }, ["permalink"]), output({}), inputResource("channels", ["permalink"], { kind: "slack-permalink", urlPathIndex: 1 }), {
263
+ normalization: [
264
+ "parse channel and timestamp from HTTPS Slack permalink; reply URL requires thread_ts",
265
+ "URL supplies identifiers only; never controls API origin",
266
+ ],
267
+ }),
268
+ operation("slack", "get_thread", object({
269
+ channel: text(100),
270
+ ts: text(100),
271
+ permalink: text(4096),
272
+ cursor: text(4096, 0),
273
+ limit: integer,
274
+ }, ["channel", "ts"]), output({}), inputResource("channels", ["channel"]), {
275
+ defaults: { limit: 15 },
276
+ normalization: ["one thread page; maximum limit 15; reject permalink conflicts"],
277
+ }),
278
+ operation("sentry", "list_organizations", object({}), listOutput("organizations")),
279
+ operation("sentry", "list_projects", object({ organization: text(100) }, ["organization"]), listOutput("projects"), inputResource("organizations", ["organization"])),
280
+ operation("airtable", "list_bases", object({}), listOutput("bases")),
281
+ operation("airtable", "list_tables", object({ base_id: text(100) }, ["base_id"]), listOutput("tables"), inputResource("bases", ["base_id"])),
282
+ operation("todoist", "list_projects", object({ limit: integer }), listOutput("projects")),
283
+ operation("todoist", "list_tasks", object({ limit: integer }), listOutput("tasks"), undefined, {
284
+ defaults: { limit: 50 },
285
+ normalization: ["limit: positive number or numeric string; clamp to 100"],
286
+ }),
287
+ operation("vercel", "list_projects", object({ team_id: text(100), limit: integer }), listOutput("projects"), undefined, {
288
+ defaults: { limit: 50 },
289
+ normalization: [
290
+ "limit: positive number or numeric string; clamp to 100",
291
+ "broker checks team_id against installation",
292
+ ],
293
+ }),
294
+ operation("vercel", "list_deployments", object({ team_id: text(100), limit: integer }), listOutput("deployments"), undefined, {
295
+ defaults: { limit: 50 },
296
+ normalization: [
297
+ "limit: positive number or numeric string; clamp to 100",
298
+ "broker checks team_id against installation; filter deployments by granted project",
299
+ ],
300
+ }),
301
+ ];
302
+ function freezeContract(value) {
303
+ if (!value || typeof value !== "object" || Object.isFrozen(value))
304
+ return;
305
+ Object.values(value).forEach(freezeContract);
306
+ Object.freeze(value);
307
+ }
308
+ freezeContract(LINK_OPERATION_CONTRACTS);
309
+ const contracts = new Map(LINK_OPERATION_CONTRACTS.map((contract) => [contract.operationId, contract]));
310
+ export function getLinkOperationContract(provider, action) {
311
+ return contracts.get(`${provider}.${action}`);
312
+ }
@@ -0,0 +1,20 @@
1
+ import type { LinkConnectionRecipe, LinkRecipeId } from "./operation-types.js";
2
+ export declare const LINK_CONNECTION_RECIPES: Readonly<Record<LinkRecipeId, LinkConnectionRecipe>>;
3
+ export declare function getLinkConnectionRecipe(id: string): LinkConnectionRecipe | undefined;
4
+ /** Compatibility at the host boundary; new records identify a reviewed recipe. */
5
+ export declare function resolveLinkRecipeId(metadata: {
6
+ recordSchemaVersion?: number;
7
+ linkRecipeId?: string;
8
+ linkExecutionBackend?: string;
9
+ linkAuthSource?: string;
10
+ }): LinkRecipeId | undefined;
11
+ interface LinkRoutingMetadata {
12
+ recordSchemaVersion?: number;
13
+ linkRecipeId?: string;
14
+ linkExecutionBackend?: "http-token" | "cli" | "remote" | "mcp";
15
+ linkExecutionRuntime?: "local" | "server";
16
+ linkAuthSource?: "manual-token" | "github-cli" | "cli-session" | "browser-oauth" | "mcp-oauth";
17
+ }
18
+ /** Read-only compatibility projection. Persist the recipe, never these derived axes. */
19
+ export declare function linkConnectionMetadata<T extends LinkRoutingMetadata>(metadata: T | undefined): Omit<T, "linkExecutionBackend" | "linkExecutionRuntime" | "linkAuthSource"> & LinkRoutingMetadata;
20
+ export {};
@@ -0,0 +1,106 @@
1
+ export const LINK_CONNECTION_RECIPES = Object.freeze({
2
+ "api-token": Object.freeze({
3
+ id: "api-token",
4
+ auth: "static-secret",
5
+ executor: "api",
6
+ route: "direct",
7
+ credentialOwner: "current-host",
8
+ executorOwner: "current-host",
9
+ }),
10
+ "api-oauth": Object.freeze({
11
+ id: "api-oauth",
12
+ auth: "oauth2",
13
+ executor: "api",
14
+ route: "direct",
15
+ credentialOwner: "current-host",
16
+ executorOwner: "current-host",
17
+ }),
18
+ "cli-session": Object.freeze({
19
+ id: "cli-session",
20
+ auth: "external-session",
21
+ executor: "cli",
22
+ route: "direct",
23
+ credentialOwner: "external-session-owner",
24
+ executorOwner: "current-host",
25
+ }),
26
+ "broker-oauth": Object.freeze({
27
+ id: "broker-oauth",
28
+ auth: "oauth2",
29
+ executor: "api",
30
+ route: "brokered",
31
+ credentialOwner: "broker",
32
+ executorOwner: "broker",
33
+ }),
34
+ "mcp-oauth": Object.freeze({
35
+ id: "mcp-oauth",
36
+ auth: "oauth2",
37
+ executor: "mcp",
38
+ route: "direct",
39
+ credentialOwner: "current-host",
40
+ executorOwner: "current-host",
41
+ }),
42
+ });
43
+ export function getLinkConnectionRecipe(id) {
44
+ return Object.hasOwn(LINK_CONNECTION_RECIPES, id)
45
+ ? LINK_CONNECTION_RECIPES[id]
46
+ : undefined;
47
+ }
48
+ /** Compatibility at the host boundary; new records identify a reviewed recipe. */
49
+ export function resolveLinkRecipeId(metadata) {
50
+ if (metadata.recordSchemaVersion !== undefined && metadata.recordSchemaVersion !== 2)
51
+ return undefined;
52
+ if (metadata.linkRecipeId)
53
+ return getLinkConnectionRecipe(metadata.linkRecipeId)?.id;
54
+ if (metadata.recordSchemaVersion === 2)
55
+ return undefined;
56
+ if (metadata.linkExecutionBackend === "remote")
57
+ return "broker-oauth";
58
+ if (metadata.linkExecutionBackend === "mcp")
59
+ return "mcp-oauth";
60
+ if (metadata.linkExecutionBackend === "cli")
61
+ return "cli-session";
62
+ if (metadata.linkAuthSource === "github-cli" || metadata.linkAuthSource === "cli-session")
63
+ return "cli-session";
64
+ if (metadata.linkAuthSource === "browser-oauth")
65
+ return "api-oauth";
66
+ if (metadata.linkExecutionBackend === "http-token")
67
+ return "api-token";
68
+ return undefined;
69
+ }
70
+ /** Read-only compatibility projection. Persist the recipe, never these derived axes. */
71
+ export function linkConnectionMetadata(metadata) {
72
+ const value = metadata ?? {};
73
+ if (!value.linkRecipeId && value.recordSchemaVersion === undefined) {
74
+ return value.linkAuthSource === "github-cli"
75
+ ? { ...value, linkAuthSource: "cli-session" }
76
+ : { ...value };
77
+ }
78
+ const recipeId = resolveLinkRecipeId(value);
79
+ const recipe = recipeId && getLinkConnectionRecipe(recipeId);
80
+ return {
81
+ ...value,
82
+ linkExecutionBackend: !recipe
83
+ ? undefined
84
+ : recipe.route === "brokered"
85
+ ? "remote"
86
+ : recipe.executor === "cli"
87
+ ? "cli"
88
+ : recipe.executor === "mcp"
89
+ ? "mcp"
90
+ : "http-token",
91
+ linkExecutionRuntime: !recipe
92
+ ? undefined
93
+ : recipe.executorOwner === "broker"
94
+ ? "server"
95
+ : "local",
96
+ linkAuthSource: recipeId === "api-token"
97
+ ? "manual-token"
98
+ : recipeId === "api-oauth"
99
+ ? "browser-oauth"
100
+ : recipeId === "cli-session"
101
+ ? "cli-session"
102
+ : recipeId === "mcp-oauth"
103
+ ? "mcp-oauth"
104
+ : undefined,
105
+ };
106
+ }
package/dist/types.d.ts CHANGED
@@ -85,7 +85,7 @@ export interface LinkProviderManifest {
85
85
  /** Reviewed exact-file reads; absence means this provider cannot supply file content. */
86
86
  fileSelection?: {
87
87
  kind: "github-file" | "figma-file";
88
- action: "get_file";
88
+ action: "get_file" | "read_design_content";
89
89
  resourceGroup: "repositories" | "files";
90
90
  };
91
91
  fileSelectionLimitation?: "document-content-unavailable";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cjhyy/code-shell-link",
3
- "version": "0.9.44",
3
+ "version": "0.9.47",
4
4
  "description": "Framework-independent provider manifests and authorization guides for CodeShell Link.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",