Skip to content

Inconsistent handling of inputSchema when schema is {} vs undefined #458

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
noctonic opened this issue May 7, 2025 · 3 comments
Open
Assignees
Labels
bug Something isn't working

Comments

@noctonic
Copy link

noctonic commented May 7, 2025

There's a significant and subtle difference in behavior when explicitly defining an empty schema {} compared to leaving the schema undefined (undefined) in tool registration.

Current Behavior

When defining a tool, the following two definitions produce distinctly different outcomes:

Case 1: Explicit empty schema ({})

server.tool(
  "no-argument-tool",
  "Call Me",
  {},  // Explicitly empty schema
  async (_extra) => ({
    content: [{ type: "text", text: "Cover me with kisses baby" }]
  })
);

EDIT: Seeing same behavior for {} and undefined

Case 2: Undefined schema (undefined) (supported by overload)

server.tool(
  "no-argument-tool",
  "Call me maybe",
  async (_extra) => ({
    content: [{ type: "text", text: "Who dis?" }]
  })
);

Client sees a minimal, non-strict schema:

{
  "name": "no-argument-tool",
  "description": "Call me maybe",
  "inputSchema": {
    "type": "object"
  }
}

Explanation

This behavior difference is unexpected because the SDK explicitly supports tool definitions without schemas via the following overload:

tool<Args extends ZodRawShape>(
  name: string,
  paramsSchemaOrAnnotations: Args | ToolAnnotations,
  cb: ToolCallback<Args>,
): RegisteredTool;

The root cause is the current logic in the SDK:

inputSchema: tool.inputSchema
  ? zodToJsonSchema(tool.inputSchema, { strictUnions: true })
  : EMPTY_OBJECT_JSON_SCHEMA

With EMPTY_OBJECT_JSON_SCHEMA defined as:

const EMPTY_OBJECT_JSON_SCHEMA = {
  type: "object" as const,
};

Relevant code

@noctonic
Copy link
Author

noctonic commented May 7, 2025

I am seeing the same behavior for {} and undefined now. Possibly related to #453

@bhosmer-ant
Copy link
Contributor

bhosmer-ant commented May 8, 2025

Just pushed a fix to #453, would you mind checking to see how it affects this issue? (And if it still happens, a repro would be super helpful :) Thanks!

@noctonic
Copy link
Author

noctonic commented May 8, 2025

The 1.11.1 release does fix the issue when the schema is defined as {} (cool) but when you omit the schema definition from the tool it gets defined as EMPTY_OBJECT_JSON_SCHEMA.

You can verify this behavior with inspector by running the server below ,connecting over SSE, and running tools/list

See screenshots showing different behavior when schema is {} vs undefined.

Good tool:

server.tool(
  "crashtest",
  `Some Description`,
  {},
  async (_extra) => {
    return {
      content: [{ type: "text", text: "did it work?" }]
    };
  }
);

Result:
Image

Bad tool:

server.tool(
  "crashtest",
  `Some Description`,
  async (_extra) => {
    return {
      content: [{ type: "text", text: "did it work?" }]
    };
  }
);

Result:
Image

test mcp server:

import express from "express";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import { z } from "zod";

const server = new McpServer({
  name: "crashtest",
  version: "1.0.0"
});

server.tool(
  "crashtest",
  `Some Description`,
  {}, // remove this line to trigger bug
  async (_extra) => {
    return {
      content: [{ type: "text", text: "did it work?" }]
    };
  }
);

const app = express();
app.use(express.json());

app.use((req, res, next) => {
  next();
});

const transports = {
  streamable: {} as Record<string, StreamableHTTPServerTransport>,
  sse: {} as Record<string, SSEServerTransport>
};

// Streamable HTTP endpoint placeholder
app.all('/mcp', async (req, res) => {
  console.log(`[MCP HTTP] ${req.method} ${req.originalUrl}`);
});

// SSE endpoint
app.get('/sse', async (req, res) => {
  console.log(`[SSE] New connection from ${req.ip}`);
  const transport = new SSEServerTransport('/messages', res);
  transports.sse[transport.sessionId] = transport;
  res.on("close", () => {
    delete transports.sse[transport.sessionId];
  });
  await server.connect(transport);
});

// Message endpoint
app.post('/messages', async (req, res) => {
  const sessionId = req.query.sessionId as string;
  const transport = transports.sse[sessionId];
  if (transport) {
    await transport.handlePostMessage(req, res, req.body);
  } else {
    res.status(400).send('No transport found for sessionId');
  }
});

// Start server
app.listen(8080, () => {
  console.log(`Server listening on port 8080`);
});

@bhosmer-ant bhosmer-ant self-assigned this May 8, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working
Projects
None yet
Development

No branches or pull requests

2 participants