Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/mcp-server-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
"@effect/ai": minor
---

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.
35 changes: 33 additions & 2 deletions packages/ai/ai/src/McpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,9 +320,11 @@ const SUPPORTED_PROTOCOL_VERSIONS = [
export const run: (options: {
readonly name: string
readonly version: string
readonly instructions?: string | undefined
}) => Effect.Effect<never, never, McpServer | RpcServer.Protocol> = 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))
Expand Down Expand Up @@ -468,6 +470,7 @@ export const run: (options: {
export const layer = (options: {
readonly name: string
readonly version: string
readonly instructions?: string | undefined
}): Layer.Layer<McpServer | McpServerClient, never, RpcServer.Protocol> =>
Layer.scopedDiscard(Effect.forkScoped(run(options))).pipe(
Layer.provideMerge(McpServer.layer)
Expand Down Expand Up @@ -535,6 +538,14 @@ export const layerStdio = <EIn, RIn, EOut, ROut>(options: {
readonly version: string
readonly stdin: Stream<Uint8Array, EIn, RIn>
readonly stdout: Sink<unknown, Uint8Array | string, unknown, EOut, ROut>
/**
* 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, RIn | ROut> =>
layer(options).pipe(
Layer.provide(
Expand Down Expand Up @@ -612,6 +623,14 @@ export const layerHttp = <I = HttpRouter.Default>(options: {
readonly version: string
readonly path: HttpRouter.PathInput
readonly routerTag?: HttpRouter.HttpRouter.TagClass<I, string, any, any>
/**
* 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> =>
layer(options).pipe(
Layer.provide(RpcServer.layerProtocolHttp(options)),
Expand All @@ -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())
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
76 changes: 76 additions & 0 deletions packages/ai/ai/test/McpServer.test.ts
Original file line number Diff line number Diff line change
@@ -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<any> =>
Effect.gen(function*() {
const stdinMailbox = yield* Mailbox.make<Uint8Array>()
const stdoutMailbox = yield* Mailbox.make<Uint8Array | string>()

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<any>

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)
}))
})
})
Loading