skybridge 2.0.4 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/README.md +9 -9
  2. package/dist/cli/run-plain.js +2 -1
  3. package/dist/cli/run-plain.js.map +1 -1
  4. package/dist/cli/use-nodemon.d.ts +11 -1
  5. package/dist/cli/use-nodemon.js +19 -5
  6. package/dist/cli/use-nodemon.js.map +1 -1
  7. package/dist/commands/dev.js +3 -3
  8. package/dist/commands/dev.js.map +1 -1
  9. package/dist/server/app.d.ts +22 -5
  10. package/dist/server/app.js +24 -13
  11. package/dist/server/app.js.map +1 -1
  12. package/dist/server/app.test.js +81 -0
  13. package/dist/server/app.test.js.map +1 -1
  14. package/dist/server/auth/providers/custom.d.ts +4 -2
  15. package/dist/server/auth/providers/custom.js.map +1 -1
  16. package/dist/server/auth/providers/workos.d.ts +3 -2
  17. package/dist/server/auth/providers/workos.js +2 -1
  18. package/dist/server/auth/providers/workos.js.map +1 -1
  19. package/dist/server/auth/verify.d.ts +5 -3
  20. package/dist/server/auth/verify.js.map +1 -1
  21. package/dist/server/auth/verify.test.js +16 -0
  22. package/dist/server/auth/verify.test.js.map +1 -1
  23. package/dist/server/events.d.ts +113 -0
  24. package/dist/server/events.js +318 -0
  25. package/dist/server/events.js.map +1 -0
  26. package/dist/server/events.test.d.ts +1 -0
  27. package/dist/server/events.test.js +172 -0
  28. package/dist/server/events.test.js.map +1 -0
  29. package/dist/server/express.d.ts +3 -2
  30. package/dist/server/express.js +17 -11
  31. package/dist/server/express.js.map +1 -1
  32. package/dist/server/express.test.js +45 -0
  33. package/dist/server/express.test.js.map +1 -1
  34. package/dist/server/form-input.d.ts +120 -0
  35. package/dist/server/form-input.js +89 -0
  36. package/dist/server/form-input.js.map +1 -0
  37. package/dist/server/index.d.ts +4 -2
  38. package/dist/server/index.js +3 -1
  39. package/dist/server/index.js.map +1 -1
  40. package/dist/server/mcp-errors.test.js +188 -0
  41. package/dist/server/mcp-errors.test.js.map +1 -1
  42. package/dist/server/metric.d.ts +5 -3
  43. package/dist/server/metric.js +5 -5
  44. package/dist/server/metric.js.map +1 -1
  45. package/dist/server/register-tool.test.js +203 -0
  46. package/dist/server/register-tool.test.js.map +1 -1
  47. package/dist/server/server.d.ts +254 -10
  48. package/dist/server/server.js +262 -9
  49. package/dist/server/server.js.map +1 -1
  50. package/dist/server/tool-extra.test.js +79 -0
  51. package/dist/server/tool-extra.test.js.map +1 -1
  52. package/dist/web/bridges/adaptor.d.ts +14 -3
  53. package/dist/web/bridges/adaptor.js +164 -14
  54. package/dist/web/bridges/adaptor.js.map +1 -1
  55. package/dist/web/bridges/adaptor.test.js +62 -6
  56. package/dist/web/bridges/adaptor.test.js.map +1 -1
  57. package/dist/web/bridges/apps-sdk/bridge.js +1 -1
  58. package/dist/web/bridges/apps-sdk/bridge.js.map +1 -1
  59. package/dist/web/bridges/apps-sdk/types.d.ts +2 -3
  60. package/dist/web/bridges/apps-sdk/types.js.map +1 -1
  61. package/dist/web/bridges/apps-sdk/use-apps-sdk-context.d.ts +3 -3
  62. package/dist/web/bridges/apps-sdk/use-apps-sdk-context.js +3 -3
  63. package/dist/web/bridges/apps-sdk/use-apps-sdk-context.js.map +1 -1
  64. package/dist/web/bridges/mcp-app/bridge.d.ts +1 -1
  65. package/dist/web/bridges/mcp-app/bridge.js +9 -2
  66. package/dist/web/bridges/mcp-app/bridge.js.map +1 -1
  67. package/dist/web/bridges/mcp-app/types.d.ts +18 -3
  68. package/dist/web/bridges/mcp-app/types.js.map +1 -1
  69. package/dist/web/bridges/mcp-app/use-mcp-app-context.d.ts +2 -2
  70. package/dist/web/bridges/mcp-app/use-mcp-app-context.js +1 -1
  71. package/dist/web/bridges/mcp-app/use-mcp-app-context.js.map +1 -1
  72. package/dist/web/bridges/types.d.ts +57 -4
  73. package/dist/web/bridges/types.js +1 -1
  74. package/dist/web/bridges/types.js.map +1 -1
  75. package/dist/web/data-llm.js +3 -2
  76. package/dist/web/data-llm.js.map +1 -1
  77. package/dist/web/data-llm.test.js +6 -0
  78. package/dist/web/data-llm.test.js.map +1 -1
  79. package/dist/web/generate-helpers.d.ts +2 -2
  80. package/dist/web/generate-helpers.js +2 -2
  81. package/dist/web/generate-helpers.js.map +1 -1
  82. package/dist/web/hooks/index.d.ts +5 -1
  83. package/dist/web/hooks/index.js +5 -1
  84. package/dist/web/hooks/index.js.map +1 -1
  85. package/dist/web/hooks/openai-extensions.test.d.ts +1 -0
  86. package/dist/web/hooks/openai-extensions.test.js +107 -0
  87. package/dist/web/hooks/openai-extensions.test.js.map +1 -0
  88. package/dist/web/hooks/use-call-tool.d.ts +2 -2
  89. package/dist/web/hooks/use-call-tool.js +15 -10
  90. package/dist/web/hooks/use-call-tool.js.map +1 -1
  91. package/dist/web/hooks/use-call-tool.test.js +13 -2
  92. package/dist/web/hooks/use-call-tool.test.js.map +1 -1
  93. package/dist/web/hooks/use-deep-link.d.ts +22 -0
  94. package/dist/web/hooks/use-deep-link.js +34 -0
  95. package/dist/web/hooks/use-deep-link.js.map +1 -0
  96. package/dist/web/hooks/use-file-resource.d.ts +37 -0
  97. package/dist/web/hooks/use-file-resource.js +98 -0
  98. package/dist/web/hooks/use-file-resource.js.map +1 -0
  99. package/dist/web/hooks/use-file-resource.test.d.ts +1 -0
  100. package/dist/web/hooks/use-file-resource.test.js +65 -0
  101. package/dist/web/hooks/use-file-resource.test.js.map +1 -0
  102. package/dist/web/hooks/use-host.d.ts +27 -5
  103. package/dist/web/hooks/use-host.js +20 -5
  104. package/dist/web/hooks/use-host.js.map +1 -1
  105. package/dist/web/hooks/use-model-context.d.ts +44 -0
  106. package/dist/web/hooks/use-model-context.js +63 -0
  107. package/dist/web/hooks/use-model-context.js.map +1 -0
  108. package/dist/web/hooks/use-open-file.d.ts +20 -0
  109. package/dist/web/hooks/use-open-file.js +25 -0
  110. package/dist/web/hooks/use-open-file.js.map +1 -0
  111. package/dist/web/hooks/use-register-view-tool.d.ts +1 -1
  112. package/dist/web/hooks/use-register-view-tool.js +1 -1
  113. package/dist/web/hooks/use-register-view-tool.js.map +1 -1
  114. package/dist/web/hooks/use-send-follow-up-message.d.ts +8 -2
  115. package/dist/web/hooks/use-send-follow-up-message.js +6 -0
  116. package/dist/web/hooks/use-send-follow-up-message.js.map +1 -1
  117. package/package.json +6 -5
@@ -1,15 +1,32 @@
1
1
  import crypto from "node:crypto";
2
2
  import { readFileSync } from "node:fs";
3
3
  import path from "node:path";
4
- import { McpServer as McpServerBase, } from "@modelcontextprotocol/server";
4
+ import { isInputRequiredResult, McpServer as McpServerBase, } from "@modelcontextprotocol/server";
5
+ import { z } from "zod/v4";
5
6
  import { warnOnLargeToolOutput } from "../context-warnings.js";
6
7
  import { authToSecuritySchemes, evaluateSecuritySchemes, inBandChallengeResult, } from "./auth/security-schemes.js";
8
+ import { EventRegistry, } from "./events.js";
7
9
  import { hostFromUserAgent } from "./host.js";
8
10
  import { captureToolError } from "./middleware.js";
9
11
  import { resolveServerOrigin } from "./requestOrigin.js";
10
12
  import { discoverSkills, registerSkills, SKILLS_EXTENSION_KEY, } from "./skills.js";
11
13
  import { templateHelper } from "./templateHelper.js";
12
14
  const unionOf = (base, extra) => extra ? [...new Set([...base, ...extra])] : base;
15
+ function mergeOpenAIUi(meta, values) {
16
+ const defined = Object.fromEntries(Object.entries(values).filter(([, value]) => value !== undefined));
17
+ if (Object.keys(defined).length > 0) {
18
+ meta["openai/ui"] = { ...meta["openai/ui"], ...defined };
19
+ }
20
+ }
21
+ function acceptsEmptyInput(inputSchema) {
22
+ if (inputSchema === undefined) {
23
+ return true;
24
+ }
25
+ const checks = "~standard" in inputSchema
26
+ ? [inputSchema["~standard"].validate({})]
27
+ : Object.values(inputSchema).map((schema) => schema["~standard"].validate(undefined));
28
+ return checks.every((result) => result instanceof Promise || result.issues === undefined);
29
+ }
13
30
  const SKILLS_DIR = "src/skills";
14
31
  /**
15
32
  * Normalize an `x-forwarded-prefix` value into a leading-slash, no-trailing-slash
@@ -24,6 +41,12 @@ function normalizeForwardedPrefix(raw) {
24
41
  }
25
42
  return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
26
43
  }
44
+ const fileEntrypointInput = {
45
+ file: z.object({
46
+ name: z.string().min(1),
47
+ resourceUri: z.string().trim().min(1),
48
+ }),
49
+ };
27
50
  /**
28
51
  * Drop the query string from a `ui://` view URI, leaving the bare path. The
29
52
  * `?v=` cache key is the only query we append, so a plain split is enough and
@@ -138,6 +161,7 @@ export class McpServer extends McpServerBaseOmitted {
138
161
  resolveResourceMetadataUrl;
139
162
  toolSecuritySchemes = new Map();
140
163
  userMiddlewareEntries = [];
164
+ eventRegistry;
141
165
  constructor(serverInfo, options, skybridgeOptions) {
142
166
  super(serverInfo, withSkillsCapability(options, skybridgeOptions));
143
167
  this.oauthEnabled = Boolean(skybridgeOptions?.oauth);
@@ -331,7 +355,7 @@ export class McpServer extends McpServerBaseOmitted {
331
355
  }
332
356
  return { serverUrl, assetsBasePath, connectDomains, contentMetaOverrides };
333
357
  }
334
- registerViewResources(toolName, view, toolMeta) {
358
+ registerViewResources(toolName, view, toolMeta, openai) {
335
359
  // Append a content-derived version param so hosts (e.g. ChatGPT) bust
336
360
  // their cache when the bundle changes, but keep the URI stable across
337
361
  // `tools/list` calls when the bundle hasn't changed.
@@ -366,10 +390,12 @@ export class McpServer extends McpServerBaseOmitted {
366
390
  "openai/widgetCSP": { redirect_domains: view.csp.redirectDomains },
367
391
  }),
368
392
  };
369
- if (view._meta) {
370
- return { ...base, ...view._meta };
371
- }
372
- return base;
393
+ const meta = { ...base, ...view._meta };
394
+ mergeOpenAIUi(meta, {
395
+ availableDisplayModes: openai?.availableDisplayModes,
396
+ preferredDisplayMode: openai?.preferredDisplayMode,
397
+ });
398
+ return meta;
373
399
  },
374
400
  };
375
401
  this.registerViewResource({ name: toolName, viewResource, view });
@@ -441,6 +467,9 @@ export class McpServer extends McpServerBaseOmitted {
441
467
  captureToolError(extra, error);
442
468
  throw error;
443
469
  }
470
+ if (isInputRequiredResult(result)) {
471
+ return result;
472
+ }
444
473
  warnOnLargeToolOutput(result, toolName);
445
474
  return {
446
475
  ...result,
@@ -504,11 +533,216 @@ export class McpServer extends McpServerBaseOmitted {
504
533
  cachedDiskManifest ??= JSON.parse(readFileSync(path.join(process.cwd(), "dist", "assets", ".vite", "manifest.json"), "utf-8"));
505
534
  return cachedDiskManifest ?? {};
506
535
  }
536
+ /**
537
+ * @experimental Register an MCP Events type that hosts can subscribe to, per
538
+ * the Triggers & Events working group draft. Skybridge serves `events/list`,
539
+ * `events/subscribe` and `events/unsubscribe`, checks auth and arguments,
540
+ * verifies the callback URL, then hands each subscription to `onSubscribe`.
541
+ * Delivering the events is up to you, for example with {@link deliverEvent}.
542
+ * API may change.
543
+ *
544
+ * @example
545
+ * ```ts
546
+ * server.registerEvent(
547
+ * {
548
+ * name: "comment.created",
549
+ * description: "Fires when someone comments on a document",
550
+ * inputSchema: { documentId: z.string() },
551
+ * payloadSchema: { documentId: z.string(), excerpt: z.string() },
552
+ * },
553
+ * {
554
+ * onSubscribe: ({ documentId }, { subscription }) =>
555
+ * webhooks.upsert({ ...subscription, filter: { documentId } }),
556
+ * onUnsubscribe: (_args, { subscription }) =>
557
+ * webhooks.remove(subscription.id),
558
+ * },
559
+ * );
560
+ * ```
561
+ *
562
+ * @see https://docs.skybridge.tech/api-reference/register-event
563
+ */
564
+ registerEvent(config, hooks) {
565
+ if (!this.eventRegistry) {
566
+ this.server.registerCapabilities({ events: {} });
567
+ this.eventRegistry = new EventRegistry(this.server);
568
+ }
569
+ this.eventRegistry.add(config, hooks);
570
+ return this;
571
+ }
572
+ /**
573
+ * Register a ChatGPT file viewer, from the OpenAI MCP extensions: a tool
574
+ * with a file entrypoint for `extensions` and a view. ChatGPT desktop calls
575
+ * it with the file the user opened, as `{ file: { name, resourceUri } }`.
576
+ * Read and save the file from the view with `useFileResource`.
577
+ *
578
+ * @example
579
+ * ```ts
580
+ * server.registerFileViewer(
581
+ * {
582
+ * name: "part-viewer",
583
+ * title: "Part Viewer",
584
+ * extensions: [".stl", ".step"],
585
+ * view: { component: "part-viewer" },
586
+ * },
587
+ * async ({ file }) => ({ structuredContent: { name: file.name } }),
588
+ * );
589
+ * ```
590
+ *
591
+ * @see https://docs.skybridge.tech/api-reference/register-file-viewer
592
+ */
593
+ registerFileViewer(config, cb) {
594
+ const { extensions, openai, ...toolConfig } = config;
595
+ return this.registerTool({
596
+ ...toolConfig,
597
+ inputSchema: fileEntrypointInput,
598
+ openai: { ...openai, entrypoints: [{ type: "file", extensions }] },
599
+ }, cb);
600
+ }
601
+ /**
602
+ * Register the composer at-mention search for ChatGPT, from the OpenAI MCP
603
+ * extensions. When the user types `@` and your app name, ChatGPT calls
604
+ * `handler` with the typed text and lists the returned resource links.
605
+ * Skybridge registers it as a tool hidden from the model.
606
+ *
607
+ * @example
608
+ * ```ts
609
+ * server.registerMentions({
610
+ * name: "search_parts",
611
+ * handler: async ({ query }) => ({
612
+ * items: findParts(query).map((part) => ({
613
+ * type: "resource_link",
614
+ * uri: `parts://${part.id}`,
615
+ * name: part.name,
616
+ * })),
617
+ * }),
618
+ * });
619
+ * ```
620
+ *
621
+ * @see https://docs.skybridge.tech/api-reference/register-mentions
622
+ */
623
+ registerMentions(config) {
624
+ this.registerTool({
625
+ name: config.name,
626
+ description: config.description ?? "Search items to mention.",
627
+ annotations: { readOnlyHint: true },
628
+ inputSchema: { query: z.string() },
629
+ _meta: {
630
+ "openai/extensions": { "mentions/search": {} },
631
+ ui: { visibility: ["app"] },
632
+ },
633
+ }, async ({ query }, extra) => ({
634
+ content: [],
635
+ structuredContent: await config.handler({ query }, extra),
636
+ }));
637
+ return this;
638
+ }
639
+ /**
640
+ * Register app settings for ChatGPT, from the OpenAI MCP extensions.
641
+ * Skybridge registers a read tool and an update tool from `fields`, and
642
+ * advertises them in the `openai/settings` server capability. ChatGPT shows
643
+ * the settings page and calls `update` with the changed fields only. Store
644
+ * the values yourself, for example per user.
645
+ *
646
+ * @example
647
+ * ```ts
648
+ * server.registerSettings({
649
+ * fields: {
650
+ * units: { title: "Units", schema: z.enum(["mm", "in"]) },
651
+ * showGrid: { title: "Show grid", schema: z.boolean() },
652
+ * },
653
+ * read: (extra) => loadSettings(extra),
654
+ * update: (set, extra) => saveSettings(set, extra),
655
+ * });
656
+ * ```
657
+ *
658
+ * @see https://docs.skybridge.tech/api-reference/register-settings
659
+ */
660
+ registerSettings(config) {
661
+ const { fields, layout, readTool = "settings.read", updateTool = "settings.update", } = config;
662
+ const values = z
663
+ .strictObject(Object.fromEntries(Object.entries(fields).map(([name, { schema, title, description }]) => [
664
+ name,
665
+ schema.meta({
666
+ title,
667
+ ...(description !== undefined && { description }),
668
+ }),
669
+ ])))
670
+ .required();
671
+ const { $schema: _, ...schema } = z.toJSONSchema(values);
672
+ for (const [name, property] of Object.entries(schema.properties ?? {})) {
673
+ if (fields[name]?.schema.safeParse(undefined).data !== undefined) {
674
+ throw new Error(`Setting "${name}" declares a default: return current values from \`read\` instead.`);
675
+ }
676
+ if (typeof property === "boolean" ||
677
+ !["boolean", "string", "number", "integer"].includes(String(property.type)) ||
678
+ (property.enum !== undefined && property.type !== "string")) {
679
+ throw new Error(`Setting "${name}" must be a boolean, string, string enum, number or integer.`);
680
+ }
681
+ }
682
+ const placed = new Set();
683
+ for (const group of layout ?? []) {
684
+ for (const item of group.items) {
685
+ if (item.kind !== "property") {
686
+ continue;
687
+ }
688
+ if (!Object.hasOwn(fields, item.property) ||
689
+ placed.has(item.property)) {
690
+ throw new Error(`Settings layout names an unknown or duplicate field "${item.property}".`);
691
+ }
692
+ placed.add(item.property);
693
+ }
694
+ }
695
+ this.registerTool({
696
+ name: readTool,
697
+ description: "Read the app settings.",
698
+ annotations: { readOnlyHint: true, openWorldHint: false },
699
+ inputSchema: {},
700
+ outputSchema: {
701
+ schema: z.record(z.string(), z.unknown()),
702
+ values,
703
+ layout: z.array(z.record(z.string(), z.unknown())).optional(),
704
+ },
705
+ }, async (_args, extra) => ({
706
+ content: [],
707
+ structuredContent: {
708
+ schema,
709
+ values: values.parse(await config.read(extra)),
710
+ layout,
711
+ },
712
+ }));
713
+ this.registerTool({
714
+ name: updateTool,
715
+ description: "Update the app settings.",
716
+ annotations: {
717
+ destructiveHint: false,
718
+ idempotentHint: true,
719
+ openWorldHint: false,
720
+ },
721
+ inputSchema: {
722
+ set: values
723
+ .partial()
724
+ .refine((set) => Object.keys(set).length > 0)
725
+ .meta({ minProperties: 1 }),
726
+ },
727
+ outputSchema: { values },
728
+ }, async ({ set }, extra) => ({
729
+ content: [],
730
+ structuredContent: {
731
+ values: values.parse(await config.update(set, extra)),
732
+ },
733
+ }));
734
+ const capability = { readTool, updateTool };
735
+ this.server.registerCapabilities({
736
+ extensions: { "openai/settings": capability },
737
+ experimental: { "openai/settings": capability },
738
+ });
739
+ return this;
740
+ }
507
741
  registerTool(rawConfig, rawCb) {
508
742
  const baseFn = McpServerBase.prototype.registerTool;
509
743
  const config = rawConfig;
510
744
  const cb = rawCb;
511
- const { name, view, auth, securitySchemes: rawSecuritySchemes, _meta: userToolMeta, ...toolFields } = config;
745
+ const { name, view, auth, securitySchemes: rawSecuritySchemes, openai, _meta: userToolMeta, ...toolFields } = config;
512
746
  const authNeedsProvider = auth !== undefined &&
513
747
  (!auth.allowsAnonymous || Boolean(auth.scopes?.length));
514
748
  if (rawSecuritySchemes === undefined &&
@@ -525,12 +759,31 @@ export class McpServer extends McpServerBaseOmitted {
525
759
  // descriptor, but the SDK's `registerTool` drops unknown top-level
526
760
  // fields, so the canonical spot isn't reachable without intercepting
527
761
  // `tools/list`. Use the `_meta` back-compat mirror documented in the
528
- // Apps SDK reference until SEP-1488 lands in the spec.
762
+ // ChatGPT plugin reference until SEP-1488 lands in the spec.
529
763
  toolMeta.securitySchemes = securitySchemes;
530
764
  }
765
+ if (openai) {
766
+ if (!view) {
767
+ throw new Error(`skybridge: tool "${name}" sets \`openai\` options but has no \`view\`.`);
768
+ }
769
+ const opensWithoutInput = openai.entrypoints?.some(({ type }) => type === "global" || type === "thread");
770
+ if (opensWithoutInput && !acceptsEmptyInput(toolFields.inputSchema)) {
771
+ throw new Error(`skybridge: tool "${name}" has a global or thread entrypoint, so ChatGPT calls it with \`{}\`, but its input schema rejects \`{}\`. Make every input optional.`);
772
+ }
773
+ const badExtension = openai.entrypoints
774
+ ?.flatMap((entrypoint) => entrypoint.type === "file" ? entrypoint.extensions : [])
775
+ .find((extension) => !extension.startsWith("."));
776
+ if (badExtension !== undefined) {
777
+ throw new Error(`skybridge: tool "${name}" has a file entrypoint extension "${badExtension}" that doesn't start with ".".`);
778
+ }
779
+ mergeOpenAIUi(toolMeta, {
780
+ entrypoints: openai.entrypoints,
781
+ preferredModelDisplayMode: openai.preferredModelDisplayMode,
782
+ });
783
+ }
531
784
  if (view) {
532
785
  this.enforceOneToolPerView(view.component, name);
533
- this.registerViewResources(name, view, toolMeta);
786
+ this.registerViewResources(name, view, toolMeta, openai);
534
787
  }
535
788
  const wrappedCb = this.decorateToolHandler(cb, {
536
789
  attachViewUUID: Boolean(view),