diff --git a/docs/api-reference/mcp-server.mdx b/docs/api-reference/mcp-server.mdx index 2909a211d..836241b99 100644 --- a/docs/api-reference/mcp-server.mdx +++ b/docs/api-reference/mcp-server.mdx @@ -68,6 +68,22 @@ server.registerTool(config, handler): this; Registers a tool, optionally bound to a view. See [registerTool](/api-reference/register-tool) for the full config and handler. +### `registerResource` + +```ts +server.registerResource(config, handler): this; +``` + +Registers a [resource](/build/resources) the host can read by URI, static or template-based. See [registerResource](/api-reference/register-resource). + +### `registerPrompt` + +```ts +server.registerPrompt(config, handler): this; +``` + +Registers a [prompt](/build/prompts) the user can invoke from the host's prompt menu. See [registerPrompt](/api-reference/register-prompt). + ### `use` ```ts diff --git a/docs/api-reference/register-prompt.mdx b/docs/api-reference/register-prompt.mdx new file mode 100644 index 000000000..26ff45c0f --- /dev/null +++ b/docs/api-reference/register-prompt.mdx @@ -0,0 +1,90 @@ +--- +title: registerPrompt +description: "Ship a reusable prompt users can invoke" +--- + +`registerPrompt` adds a [prompt](/build/prompts) to your server: a named, parameterized message template the user picks from the host's prompt menu. The handler returns the messages the host inserts into the conversation. + +## Example + +A prompt that takes one argument and returns a single user message. + +```ts server.ts +import { McpServer } from "skybridge/server"; +import { z } from "zod"; + +const server = new McpServer({ name: "travel", version: "1.0" }).registerPrompt( + { + name: "trip-summary", + title: "Trip summary", + description: "Summarize a trip to a destination.", + argsSchema: { destination: z.string() }, + }, + ({ destination }) => ({ + messages: [ + { + role: "user", + content: { type: "text", text: `Summarize a trip to ${destination}.` }, + }, + ], + }), +); +``` + +## Signature + +```ts +server.registerPrompt(config: PromptConfig, handler: PromptHandler): McpServer; +``` + +The config-object form returns the server, so it chains alongside [`registerTool`](/api-reference/register-tool) and [`registerResource`](/api-reference/register-resource). + +## Config + +```ts +type PromptConfig = { + name: string; + title?: string; + description?: string; + argsSchema?: ZodRawShape; // Zod shape; validates args and types the handler +}; +``` + +### `name`, `title`, `description` + +`name` identifies the prompt; `title` and `description` are what the host shows in its prompt menu. + +### `argsSchema` + +A [Zod](https://zod.dev/) raw shape whose fields become the prompt's arguments. It validates what the host passes and types the handler's input. Wrap a field in `completable()` to autocomplete it as the user types, see [Completions](/build/prompts#completions). + +## Handler + +Receives the validated arguments and returns the messages to insert. + +```ts +type PromptHandler = ( + args: Args, + extra: RequestHandlerExtra, +) => Promise<{ + description?: string; + messages: Array<{ + role: "user" | "assistant"; + content: ContentBlock; + }>; +}>; +``` + +Each message's `content` is a standard MCP [`ContentBlock`](/api-reference/register-tool#content-helpers), most often `{ type: "text", text }`. + + + + The guide, with completions + + + Expose readable content + + + The server you register on + + diff --git a/docs/api-reference/register-resource.mdx b/docs/api-reference/register-resource.mdx new file mode 100644 index 000000000..94c02ce68 --- /dev/null +++ b/docs/api-reference/register-resource.mdx @@ -0,0 +1,135 @@ +--- +title: registerResource +description: "Expose a resource the host can read" +--- + +`registerResource` adds a [resource](/build/resources) to your server: addressable content a host can read by URI, such as reference docs, a data file, or a live-generated report. Pass a `uri` for a fixed resource or a `template` for a family of URIs. + +## Example + +A static Markdown document at a fixed URI. + +```ts server.ts +import { McpServer } from "skybridge/server"; + +const server = new McpServer({ name: "shop", version: "1.0" }).registerResource( + { + name: "pricing", + uri: "docs://pricing", + title: "Pricing", + description: "Current plan pricing, in Markdown.", + mimeType: "text/markdown", + }, + async (uri) => ({ + contents: [{ uri: uri.href, text: await loadPricingDoc() }], + }), +); +``` + +## Signature + +```ts +server.registerResource(config: ResourceConfig, handler: ReadHandler): McpServer; +``` + +The config-object form returns the server, so it chains alongside [`registerTool`](/api-reference/register-tool) and [`registerPrompt`](/api-reference/register-prompt). + +## Config + +Provide either `uri` (static) or `template` (dynamic), never both. The remaining fields are the resource's metadata. + +```ts +type ResourceConfig = + | { name: string; uri: string } & ResourceMetadata + | { name: string; template: ResourceTemplate } & ResourceMetadata; + +type ResourceMetadata = { + title?: string; + description?: string; + mimeType?: string; + _meta?: Record; +}; +``` + +### `name` + +Identifier for the resource, unique per server. + +### `uri` + +The fixed URI a static resource is read from, e.g. `docs://pricing`. The handler receives this back as a `URL`. + + +The `ui://views/` namespace is reserved for Skybridge view resources. Registering a resource under it throws. + + +### `template` + +A [`ResourceTemplate`](https://github.com/modelcontextprotocol/typescript-sdk) for a family of URIs, described by an [RFC 6570](https://www.rfc-editor.org/rfc/rfc6570) URI template. Use it when the URI carries a variable, such as a record id. + +```ts +import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; + +server.registerResource( + { + name: "order", + template: new ResourceTemplate("orders://{orderId}", { + // List concrete resources the template can produce (or `undefined`). + list: async () => ({ + resources: (await recentOrders()).map((id) => ({ + name: id, + uri: `orders://${id}`, + })), + }), + // Autocomplete a template variable as the user types. + complete: { + orderId: async (value) => (await matchOrderIds(value)).slice(0, 20), + }, + }), + }, + async (uri, { orderId }) => ({ + contents: [{ uri: uri.href, text: await renderOrder(orderId as string) }], + }), +); +``` + +The `list` callback is required (pass `undefined` to opt out) so a resource family is never accidentally unlistable. See [Completions](/build/resources#completions). + +### `mimeType`, `title`, `description`, `_meta` + +`mimeType` labels the content (`text/markdown`, `application/json`, …). `title` and `description` are the discovery surface a host shows. `_meta` carries free-form metadata, forwarded untouched. + +## Handler + +Runs when the resource is read. A static resource's handler receives the requested `uri`; a template's also receives the resolved template `variables`. + +```ts +type ReadHandler = ( + uri: URL, + variablesOrExtra: Variables | RequestHandlerExtra, + extra?: RequestHandlerExtra, +) => Promise<{ + contents: Array<{ + uri: string; + mimeType?: string; + text?: string; // text content + blob?: string; // base64 content + _meta?: Record; + }>; + _meta?: Record; +}>; +``` + +Return `text` for text resources or `blob` (base64) for binary. Set each entry's `uri` to `uri.href` so the response echoes the URI that was requested. + + + + The guide, with completions + + + Ship reusable prompts + + + The server you register on + + diff --git a/docs/build/prompts.mdx b/docs/build/prompts.mdx new file mode 100644 index 000000000..7004ebb78 --- /dev/null +++ b/docs/build/prompts.mdx @@ -0,0 +1,78 @@ +--- +title: "Register Prompts" +description: "Ship reusable prompts users can invoke" +icon: "message-square" +--- + +A **prompt** is the third MCP server primitive, alongside [tools](/build/tools) and [resources](/build/resources). It's a named, parameterized message template the user picks from the host's prompt menu; your handler returns the messages the host drops into the conversation. Use it to ship canned starting points, "Summarize this trip", "Draft a reply", so users don't retype them. + +```ts server.ts +import { McpServer } from "skybridge/server"; +import { z } from "zod"; + +const server = new McpServer({ name: "travel", version: "1.0" }).registerPrompt( + { + name: "trip-summary", + title: "Trip summary", + description: "Summarize a trip to a destination.", + argsSchema: { destination: z.string() }, + }, + ({ destination }) => ({ + messages: [ + { + role: "user", + content: { type: "text", text: `Summarize a trip to ${destination}.` }, + }, + ], + }), +); +``` + +[`registerPrompt`](/api-reference/register-prompt) returns the server, so it chains next to your other registrations. `argsSchema` is a [Zod](https://zod.dev/) shape: it validates the arguments the host passes and types the handler's input. + + +Register prompts up front, at startup. Adding or removing them at runtime relies on `list_changed` notifications, which the stateless JSON transport can't deliver, so hosts won't see the change. + + +## Completions + +Wrap an argument in `completable()` to autocomplete it as the user fills in the prompt: + +```ts server.ts +import { completable } from "@modelcontextprotocol/sdk/server/completable.js"; +import { z } from "zod"; + +server.registerPrompt( + { + name: "trip-summary", + argsSchema: { + destination: completable(z.string(), async (value) => + (await matchCities(value)).slice(0, 20), + ), + }, + }, + ({ destination }) => ({ + messages: [ + { + role: "user", + content: { type: "text", text: `Summarize a trip to ${destination}.` }, + }, + ], + }), +); +``` + +The completion callback receives what the user has typed and returns the candidate values. + +## Go Further + + + Full config and handler reference + + + Expose readable content + + + The action primitive + + diff --git a/docs/build/resources.mdx b/docs/build/resources.mdx new file mode 100644 index 000000000..0f0779d7c --- /dev/null +++ b/docs/build/resources.mdx @@ -0,0 +1,93 @@ +--- +title: "Register Resources" +description: "Expose readable content to the host" +icon: "file-text" +--- + +MCP has three server primitives. [Tools](/build/tools) are the ones the model calls; resources and prompts round out the set. A **resource** is addressable content a host can read by URI: reference docs, a data file, a live-generated report. Where a tool is an action, a resource is a document. + +```ts server.ts +import { McpServer } from "skybridge/server"; + +const server = new McpServer({ name: "shop", version: "1.0" }).registerResource( + { + name: "pricing", + uri: "docs://pricing", + title: "Pricing", + description: "Current plan pricing, in Markdown.", + mimeType: "text/markdown", + }, + async (uri) => ({ + contents: [{ uri: uri.href, text: await loadPricingDoc() }], + }), +); +``` + +[`registerResource`](/api-reference/register-resource) returns the server, so it chains next to your tool registrations. The handler runs when the host reads the URI and returns the content: `text` for text, `blob` (base64) for binary. + + +Register resources up front, at startup. Adding or removing them at runtime relies on `list_changed` notifications, which the stateless JSON transport can't deliver, so hosts won't see the change. + + +## Static vs. dynamic + +A **static** resource lives at one fixed `uri`. A **dynamic** resource uses a `template`, an [RFC 6570](https://www.rfc-editor.org/rfc/rfc6570) URI template, to cover a family of URIs that share a shape: + +```ts server.ts +import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; + +server.registerResource( + { + name: "order", + template: new ResourceTemplate("orders://{orderId}", { list: undefined }), + }, + async (uri, { orderId }) => ({ + contents: [{ uri: uri.href, text: await renderOrder(orderId as string) }], + }), +); +``` + +The template's handler receives the resolved `variables` as its second argument. + + +The `ui://views/` namespace is reserved for the view resources Skybridge registers for you. Registering a resource under it throws. + + +## Completions + +A dynamic resource can help the host discover and complete its URIs. The `ResourceTemplate` constructor takes two callbacks: + +```ts server.ts +new ResourceTemplate("orders://{orderId}", { + // Enumerate concrete resources the template produces. + list: async () => ({ + resources: (await recentOrders()).map((id) => ({ + name: id, + uri: `orders://${id}`, + })), + }), + // Autocomplete a variable as the user types it. + complete: { + orderId: async (value) => (await matchOrderIds(value)).slice(0, 20), + }, +}); +``` + +`list` is required: pass `undefined` to opt out, so a template is never accidentally unlistable. `complete` is optional, one callback per template variable, returning the candidate values for what the user has typed so far. + + + The third primitive: reusable prompts users can invoke. + + +## Go Further + + + Full config and handler reference + + + Ship reusable prompts + + + The action primitive + + diff --git a/docs/docs.json b/docs/docs.json index 303efbf86..140b85a8b 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -38,6 +38,8 @@ "root": "build/index", "pages": [ "build/tools", + "build/resources", + "build/prompts", "build/view", "build/state", "build/auth" @@ -99,7 +101,9 @@ "icon": "server", "pages": [ "api-reference/mcp-server", - "api-reference/register-tool" + "api-reference/register-tool", + "api-reference/register-resource", + "api-reference/register-prompt" ] }, { diff --git a/packages/core/src/server/index.ts b/packages/core/src/server/index.ts index 6820d6f2e..3724f2156 100644 --- a/packages/core/src/server/index.ts +++ b/packages/core/src/server/index.ts @@ -44,6 +44,8 @@ export type { JsonOptions, KnownToolMeta, McpServerTypes, + PromptConfig, + ResourceConfig, SecurityScheme, SkybridgeServerOptions, ToolDef, diff --git a/packages/core/src/server/register-resource-prompt.test-d.ts b/packages/core/src/server/register-resource-prompt.test-d.ts new file mode 100644 index 000000000..66259dac5 --- /dev/null +++ b/packages/core/src/server/register-resource-prompt.test-d.ts @@ -0,0 +1,40 @@ +import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { expectTypeOf, test } from "vitest"; +import { z } from "zod"; +import type { McpServer } from "./server.js"; + +const server = null as unknown as McpServer; + +test("registerResource config object is chainable", () => { + expectTypeOf( + server.registerResource( + { name: "pricing", uri: "docs://pricing", mimeType: "text/markdown" }, + async (uri) => ({ contents: [{ uri: uri.href, text: "" }] }), + ), + ).toEqualTypeOf(); +}); + +test("registerResource template variant passes variables to the callback", () => { + server.registerResource( + { + name: "doc", + template: new ResourceTemplate("docs://{id}", { list: undefined }), + }, + async (uri, variables) => { + expectTypeOf(variables).toBeObject(); + return { contents: [{ uri: uri.href, text: "" }] }; + }, + ); +}); + +test("registerPrompt config object is chainable and typed args", () => { + expectTypeOf( + server.registerPrompt( + { name: "trip-summary", argsSchema: { destination: z.string() } }, + ({ destination }) => { + expectTypeOf(destination).toEqualTypeOf(); + return { messages: [] }; + }, + ), + ).toEqualTypeOf(); +}); diff --git a/packages/core/src/server/register-resource-prompt.test.ts b/packages/core/src/server/register-resource-prompt.test.ts new file mode 100644 index 000000000..cef313de1 --- /dev/null +++ b/packages/core/src/server/register-resource-prompt.test.ts @@ -0,0 +1,69 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { McpServer } from "./index.js"; + +async function connect(register: (server: McpServer) => void) { + const server = new McpServer({ name: "test", version: "1.0.0" }); + register(server); + const client = new Client({ name: "test-client", version: "1.0.0" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + return { + client, + teardown: async () => { + await client.close(); + await server.close(); + }, + }; +} + +describe("registerResource / registerPrompt config-object API", () => { + it("registers and reads a resource, chaining with registerPrompt", async () => { + const { client, teardown } = await connect((server) => { + const chained = server + .registerResource( + { name: "pricing", uri: "docs://pricing", mimeType: "text/markdown" }, + async (uri) => ({ contents: [{ uri: uri.href, text: "$5" }] }), + ) + .registerPrompt( + { name: "trip", argsSchema: { destination: z.string() } }, + ({ destination }) => ({ + messages: [ + { + role: "user", + content: { type: "text", text: `Trip to ${destination}` }, + }, + ], + }), + ); + expect(chained).toBe(server); + }); + + const { contents } = await client.readResource({ uri: "docs://pricing" }); + expect((contents[0] as { text: string }).text).toBe("$5"); + + const prompt = await client.getPrompt({ + name: "trip", + arguments: { destination: "Rome" }, + }); + expect((prompt.messages[0]?.content as { text: string }).text).toBe( + "Trip to Rome", + ); + + await teardown(); + }); + + it("rejects resources colliding with the reserved ui://views/ namespace", () => { + const server = new McpServer({ name: "test", version: "1.0.0" }); + expect(() => + server.registerResource( + { name: "sneaky", uri: "ui://views/ext-apps/x.html" }, + async (uri) => ({ contents: [{ uri: uri.href, text: "" }] }), + ), + ).toThrow(/reserved/); + }); +}); diff --git a/packages/core/src/server/server.ts b/packages/core/src/server/server.ts index fe5b3f2fc..7289f21e1 100644 --- a/packages/core/src/server/server.ts +++ b/packages/core/src/server/server.ts @@ -10,7 +10,17 @@ import { Server as SdkServer, type ServerOptions, } from "@modelcontextprotocol/sdk/server/index.js"; -import { McpServer as McpServerBase } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + McpServer as McpServerBase, + type PromptCallback, + type ReadResourceCallback, + type ReadResourceTemplateCallback, + type RegisteredPrompt, + type RegisteredResource, + type RegisteredResourceTemplate, + type ResourceMetadata, + type ResourceTemplate, +} from "@modelcontextprotocol/sdk/server/mcp.js"; import type { AnySchema, SchemaOutput, @@ -344,6 +354,33 @@ interface ToolConfig { _meta?: ToolMeta; } +/** + * Config object for {@link McpServer.registerResource}. Provide `uri` for a + * static resource or `template` (a `ResourceTemplate`) for a dynamic one; the + * remaining fields mirror the SDK's `ResourceMetadata` (`title`, `description`, + * `mimeType`, `_meta`, …). + */ +export type ResourceConfig = + | ({ name: string; uri: string; template?: never } & ResourceMetadata) + | ({ + name: string; + template: ResourceTemplate; + uri?: never; + } & ResourceMetadata); + +/** + * Config object for {@link McpServer.registerPrompt}. `argsSchema` is a Zod raw + * shape; wrap individual args in `completable()` for argument autocompletion. + */ +export interface PromptConfig< + Args extends ZodRawShapeCompat = ZodRawShapeCompat, +> { + name: string; + title?: string; + description?: string; + argsSchema?: Args; +} + /** * Optional client-supplied hints attached to `params._meta` on every tool call * by the Apps SDK host. Hints only: never use for authorization, and tolerate @@ -404,6 +441,15 @@ function stripQuery(uri: string): string { return queryIndex === -1 ? uri : uri.slice(0, queryIndex); } +/** `ui://views/` is reserved for Skybridge's internal view HTML resources. */ +function assertNotViewNamespace(uri: string): void { + if (uri.startsWith("ui://views/")) { + throw new Error( + `Cannot register resource "${uri}": the "ui://views/" namespace is reserved for Skybridge views.`, + ); + } +} + /** * Coerce a tool handler's return value into an MCP `content` array. Strings * become a single `TextContent`; a single block is wrapped in an array; @@ -425,11 +471,15 @@ export function normalizeContent( return [content]; } -// We Omit `registerTool` from the base class at the type level so our -// unified 2-arg signature can replace the SDK's 3-arg one without an -// incompatible override. The runtime prototype chain is unaffected. +// We Omit `registerTool`/`registerResource`/`registerPrompt` from the base +// class at the type level so our config-object overloads can sit alongside the +// SDK's positional ones without an incompatible override. The runtime prototype +// chain is unaffected. interface McpServerBaseOmitted - extends Omit {} + extends Omit< + McpServerBase, + "registerTool" | "registerResource" | "registerPrompt" | "connect" + > {} const McpServerBaseOmitted = McpServerBase as unknown as new ( ...args: ConstructorParameters ) => McpServerBaseOmitted; @@ -1297,4 +1347,95 @@ export class McpServer< return this; } + + /** + * Register an MCP resource. Pass a config object for a chainable, + * Skybridge-style registration; the SDK's positional overloads remain + * available for internal use. + * + * @example + * ```ts + * server.registerResource( + * { name: "pricing", uri: "docs://pricing", mimeType: "text/markdown" }, + * async (uri) => ({ contents: [{ uri: uri.href, text: pricingDoc }] }), + * ); + * ``` + * + * @see https://docs.skybridge.tech/build/resources + */ + registerResource( + config: { name: string; uri: string } & ResourceMetadata, + readCallback: ReadResourceCallback, + ): this; + registerResource( + config: { name: string; template: ResourceTemplate } & ResourceMetadata, + readCallback: ReadResourceTemplateCallback, + ): this; + registerResource( + name: string, + uri: string, + config: ResourceMetadata, + readCallback: ReadResourceCallback, + ): RegisteredResource; + registerResource( + name: string, + template: ResourceTemplate, + config: ResourceMetadata, + readCallback: ReadResourceTemplateCallback, + ): RegisteredResourceTemplate; + registerResource(...args: unknown[]): unknown { + const baseFn = McpServerBase.prototype.registerResource as ( + ...args: unknown[] + ) => unknown; + + if (typeof args[0] === "string") { + return baseFn.call(this, args[0], args[1], args[2], args[3]); + } + + const { name, uri, template, ...metadata } = args[0] as ResourceConfig; + const uriOrTemplate = template ?? uri; + assertNotViewNamespace( + template ? String(template.uriTemplate) : (uri as string), + ); + baseFn.call(this, name, uriOrTemplate, metadata, args[1]); + return this; + } + + /** + * Register an MCP prompt. Pass a config object for a chainable, + * Skybridge-style registration; the SDK's positional overloads remain + * available for internal use. + * + * @example + * ```ts + * server.registerPrompt( + * { name: "trip-summary", argsSchema: { destination: z.string() } }, + * ({ destination }) => ({ messages: [] }), + * ); + * ``` + * + * @see https://docs.skybridge.tech/build/prompts + */ + registerPrompt( + config: PromptConfig, + cb: PromptCallback, + ): this; + registerPrompt( + name: string, + config: { title?: string; description?: string; argsSchema?: Args }, + cb: PromptCallback, + ): RegisteredPrompt; + registerPrompt(...args: unknown[]): unknown { + const baseFn = McpServerBase.prototype.registerPrompt as ( + ...args: unknown[] + ) => unknown; + + if (typeof args[0] === "string") { + return baseFn.call(this, args[0], args[1], args[2]); + } + + const { name, ...config } = args[0] as PromptConfig; + baseFn.call(this, name, config, args[1]); + return this; + } } diff --git a/skills/chatgpt-app-builder/references/architecture.md b/skills/chatgpt-app-builder/references/architecture.md index d09368e9c..3af6c9b14 100644 --- a/skills/chatgpt-app-builder/references/architecture.md +++ b/skills/chatgpt-app-builder/references/architecture.md @@ -9,6 +9,8 @@ A **view** is a tool with a UI. It renders the tool output visually. The UI is a - manage its own state - call other tools to fetch data absent from the view output schema or trigger actions. +A **resource** is readable content addressed by URI (`server.registerResource`), static or template-based; a **prompt** is a reusable message template the user invokes (`server.registerPrompt`). Both chain off the server like `registerTool`. Reach for them only when the SPEC calls for exposing reference content or canned prompts; most apps need only tools and views. See `/build/resources` and `/build/prompts`. + ## Step 1: Identify the UX Flows A **flow** is an end-to-end user journey that accomplishes one goal (e.g., "book a flight" = search → select → checkout).