diff --git a/.changeset/mcp-server-instructions.md b/.changeset/mcp-server-instructions.md new file mode 100644 index 00000000000..0115766b945 --- /dev/null +++ b/.changeset/mcp-server-instructions.md @@ -0,0 +1,22 @@ +--- +"@effect/ai": patch +--- + +Add optional `instructions` to `McpServer` layer options. + +When provided, the string is returned in the `InitializeResult.instructions` +field per the [MCP specification](https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle#initialization). +Clients can use it to improve the LLM's understanding of the server (for +example, by injecting it into the system prompt). + +```ts +McpServer.layerHttp({ + name: "Demo Server", + version: "1.0.0", + path: "/mcp", + instructions: "Always greet the user with a friendly hello." +}) +``` + +The option is supported on `layer`, `layerStdio`, `layerHttp`, and +`layerHttpRouter`. It is omitted from the response when not set. diff --git a/packages/ai/ai/src/McpServer.ts b/packages/ai/ai/src/McpServer.ts index b91cebe37f4..1ba6d1e95a6 100644 --- a/packages/ai/ai/src/McpServer.ts +++ b/packages/ai/ai/src/McpServer.ts @@ -320,9 +320,11 @@ const SUPPORTED_PROTOCOL_VERSIONS = [ export const run: (options: { readonly name: string readonly version: string + readonly instructions?: string | undefined }) => Effect.Effect = Effect.fnUntraced(function*(options: { readonly name: string readonly version: string + readonly instructions?: string | undefined }) { const protocol = yield* RpcServer.Protocol const handlers = yield* Layer.build(layerHandlers(options)) @@ -468,6 +470,7 @@ export const run: (options: { export const layer = (options: { readonly name: string readonly version: string + readonly instructions?: string | undefined }): Layer.Layer => Layer.scopedDiscard(Effect.forkScoped(run(options))).pipe( Layer.provideMerge(McpServer.layer) @@ -535,6 +538,14 @@ export const layerStdio = (options: { readonly version: string readonly stdin: Stream readonly stdout: Sink + /** + * Optional instructions describing how to use the server and its features. + * + * When set, this string is returned in the `InitializeResult.instructions` + * field per the MCP specification. Clients can use it to improve the LLM's + * understanding of the server (e.g. by injecting it into the system prompt). + */ + readonly instructions?: string | undefined }): Layer.Layer => layer(options).pipe( Layer.provide( @@ -612,6 +623,14 @@ export const layerHttp = (options: { readonly version: string readonly path: HttpRouter.PathInput readonly routerTag?: HttpRouter.HttpRouter.TagClass + /** + * Optional instructions describing how to use the server and its features. + * + * When set, this string is returned in the `InitializeResult.instructions` + * field per the MCP specification. Clients can use it to improve the LLM's + * understanding of the server (e.g. by injecting it into the system prompt). + */ + readonly instructions?: string | undefined }): Layer.Layer => layer(options).pipe( Layer.provide(RpcServer.layerProtocolHttp(options)), @@ -630,11 +649,20 @@ export const layerHttpRouter = (options: { readonly name: string readonly version: string readonly path: HttpRouter.PathInput + /** + * Optional instructions describing how to use the server and its features. + * + * When set, this string is returned in the `InitializeResult.instructions` + * field per the MCP specification. Clients can use it to improve the LLM's + * understanding of the server (e.g. by injecting it into the system prompt). + */ + readonly instructions?: string | undefined }): Layer.Layer< McpServer | McpServerClient, never, HttpLayerRouter.HttpRouter > => + layer(options).pipe( Layer.provide(RpcServer.layerProtocolHttpRouter(options)), Layer.provide(RpcSerialization.layerJsonRpc()) @@ -1249,13 +1277,15 @@ const compileUriTemplate = ( } as const } -const layerHandlers = (serverInfo: { +const layerHandlers = (options: { readonly name: string readonly version: string + readonly instructions?: string | undefined }) => ClientRpcs.toLayer( Effect.gen(function*() { const server = yield* McpServer + const serverInfo = { name: options.name, version: options.version } return { // Requests @@ -1290,7 +1320,8 @@ const layerHandlers = (serverInfo: { requestedVersion ) ? requestedVersion - : LATEST_PROTOCOL_VERSION + : LATEST_PROTOCOL_VERSION, + ...(options.instructions !== undefined ? { instructions: options.instructions } : {}) }) }, "completion/complete": server.completion, diff --git a/packages/ai/ai/test/McpServer.test.ts b/packages/ai/ai/test/McpServer.test.ts new file mode 100644 index 00000000000..e614522e627 --- /dev/null +++ b/packages/ai/ai/test/McpServer.test.ts @@ -0,0 +1,76 @@ +import { assert, describe, it } from "@effect/vitest" +import { Effect, Layer, Mailbox, Sink } from "effect" +import * as McpServer from "../src/McpServer.js" + +const encoder = new TextEncoder() +const decoder = new TextDecoder() + +const initializeRequest = { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "test-client", version: "0.0.0" } + } +} + +const initializeResultFor = ( + options: { readonly instructions?: string | undefined } +): Effect.Effect => + Effect.gen(function*() { + const stdinMailbox = yield* Mailbox.make() + const stdoutMailbox = yield* Mailbox.make() + + const stdin = Mailbox.toStream(stdinMailbox) + const stdout = Sink.forEach((chunk: Uint8Array | string) => stdoutMailbox.offer(chunk)) + + const ServerLayer = McpServer.layerStdio({ + name: "test-server", + version: "1.0.0", + stdin, + stdout, + ...(options.instructions !== undefined ? { instructions: options.instructions } : {}) + }) + + yield* Effect.forkScoped(Layer.launch(ServerLayer)) + + yield* stdinMailbox.offer(encoder.encode(JSON.stringify(initializeRequest) + "\n")) + + let response: any + while (response === undefined) { + const chunk = yield* stdoutMailbox.take + const text = typeof chunk === "string" ? chunk : decoder.decode(chunk) + for (const line of text.split("\n")) { + if (line.trim() === "") continue + const parsed = JSON.parse(line) + if (parsed.id === 1) { + response = parsed + break + } + } + } + + return response + }).pipe(Effect.scoped) as Effect.Effect + +describe("McpServer", () => { + describe("instructions", () => { + it.effect("returns instructions in InitializeResult when set", () => + Effect.gen(function*() { + const response = yield* initializeResultFor({ instructions: "be helpful" }) + assert.strictEqual(response.result.instructions, "be helpful") + assert.deepStrictEqual(response.result.serverInfo, { + name: "test-server", + version: "1.0.0" + }) + })) + + it.effect("omits instructions in InitializeResult when not set", () => + Effect.gen(function*() { + const response = yield* initializeResultFor({}) + assert.isFalse("instructions" in response.result) + })) + }) +})