Skip to content

Simplified, Express-like API #117

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

Merged
merged 33 commits into from
Jan 20, 2025
Merged

Simplified, Express-like API #117

merged 33 commits into from
Jan 20, 2025

Conversation

jspahrsummers
Copy link
Member

@jspahrsummers jspahrsummers commented Jan 7, 2025

Inspired by #116 and some of the MCP SDK wrappers that have popped up in the ecosystem, this is an attempt to bring a more Express-style API into the SDK.

This diverges from the existing wrapper libraries through liberal use of overloads to achieve a highly ergonomic API supporting a variable number of parameters.

Zod (already used in the SDK) is further utilized to declare tool input shapes, and perform automatic parsing and validation. I tried to limit its syntactic overhead, though.

Examples

Tools

server.tool("save", async () => {
  return {
    content: [
      {
        type: "text",
        text: "Saved successfully.",
      },
    ],
  };
});

server.tool("echo", { text: z.string() }, async ({ text }) => {
  return {
    content: [
      {
        type: "text",
        text,
      },
    ],
  };
});

server.tool(
  "add",
  "Adds two numbers together",
  { a: z.number(), b: z.number() },
  async ({ a, b }) => {
    return {
      content: [
        {
          type: "text",
          text: String(a + b),
        },
      ],
    };
  },
);

Resources

// 1. Basic static resource with fixed URI
server.resource(
  "welcome-message",
  "test://messages/welcome", 
  async (uri) => ({
    contents: [{
      uri: uri.href,
      text: "Welcome to the server!"
    }]
  })
);

// 2. Static resource with metadata
server.resource(
  "documentation",
  "test://docs/index",
  {
    description: "Server documentation",
    mimeType: "text/markdown"
  },
  async (uri) => ({
    contents: [{
      uri: uri.href,
      text: "# Server Documentation\n\nThis is the main documentation page."
    }]
  })
);

// 3. Resource template for dynamic URIs
server.resource(
  "user-profile",
  new ResourceTemplate("test://users/{userId}/profile", { list: undefined }),
  async (uri, { userId }) => {
    return {
      contents: [{
        uri: uri.href,
        text: `Profile for user ${userId}`
      }]
    };
  }
);

// 4. Resource template with metadata
server.resource(
  "user-avatar",
  new ResourceTemplate("test://users/{userId}/avatar", { list: undefined }),
  {
    description: "User avatar image",
    mimeType: "image/png"
  },
  async (uri) => {
    // Example image data
    const imageData = new Uint8Array([0xFF, 0xD8, 0xFF]); // Example JPEG header

    return {
      contents: [{
        uri: uri.href,
        blob: Buffer.from(imageData).toString("base64")
      }]
    };
  }
);

// 5. Resource template with list capability
server.resource(
  "chat-messages",
  new ResourceTemplate(
    "test://chats/{chatId}/messages/{messageId}",
    {
      list: async () => ({
        resources: [
          {
            name: "Message 1",
            uri: "test://chats/123/messages/1"
          },
          {
            name: "Message 2", 
            uri: "test://chats/123/messages/2"
          }
        ]
      })
    }
  ),
  async (uri, { messageId, chatId }) => ({
    contents: [{
      uri: uri.href,
      text: `Message ${messageId} in chat ${chatId}`
    }]
  })
);

// 6. Resource template with metadata and list capability
server.resource(
  "photo-album",
  new ResourceTemplate(
    "test://albums/{albumId}/photos/{photoId}",
    {
      list: async () => ({
        resources: [
          {
            name: "Beach Photo",
            uri: "test://albums/vacation/photos/1",
            description: "Day at the beach"
          },
          {
            name: "Mountain Photo",
            uri: "test://albums/vacation/photos/2",
            description: "Mountain hiking"
          }
        ]
      })
    }
  ),
  {
    description: "Photo album contents",
    mimeType: "image/jpeg"
  },
  async (uri, { albumId, photoId }) => ({
    contents: [
      {
        uri: uri.href,
        blob: Buffer.from(/* photo data */).toString('base64')
      },
      {
        uri: `${uri.href}#metadata`,
        text: JSON.stringify({
          timestamp: new Date().toISOString(),
          location: "Beach",
          description: "Beautiful day at the beach"
        })
      }
    ]
  })
);

Prompts

// Basic prompt with no arguments
server.prompt("greeting", () => ({
  messages: [
    {
      role: "assistant",
      content: { type: "text", text: "Hello! How can I help you today?" }
    }
  ]
}));

// Prompt with description
server.prompt(
  "introduction",
  "A friendly introduction message for new users",
  () => ({
    messages: [
      {
        role: "assistant", 
        content: { 
          type: "text",
          text: "Welcome! I'm an AI assistant ready to help you with your tasks."
        }
      }
    ]
  })
);

// Prompt with arguments schema
server.prompt(
  "personalizedGreeting",
  {
    name: z.string(),
    language: z.string().optional()
  },
  ({ name, language }) => ({
    messages: [
      {
        role: "assistant",
        content: {
          type: "text", 
          text: `${language === "es" ? "¡Hola" : "Hello"} ${name}! How may I assist you today?`
        }
      }
    ]
  })
);

// Prompt with description and arguments schema
server.prompt(
  "customWelcome",
  "A customizable welcome message with user's name and preferred language",
  {
    name: z.string().describe("User's name"),
    language: z.enum(["en", "es", "fr"]).describe("Preferred language code"),
    formal: z.boolean().optional().describe("Whether to use formal language")
  },
  ({ name, language, formal = false }) => {
    let greeting;
    switch(language) {
      case "es":
        greeting = formal ? "Buenos días" : "¡Hola";
        break;
      case "fr":
        greeting = formal ? "Bonjour" : "Salut";
        break;
      default:
        greeting = formal ? "Good day" : "Hi";
    }

    return {
      description: "Personalized welcome message",
      messages: [
        {
          role: "assistant",
          content: {
            type: "text",
            text: `${greeting} ${name}!`
          }
        }
      ]
    };
  }
);

// Prompt with multiple messages in response
server.prompt(
  "conversation",
  {
    topic: z.string()
  },
  ({ topic }) => ({
    messages: [
      {
        role: "user",
        content: {
          type: "text",
          text: `Let's talk about ${topic}`
        }
      },
      {
        role: "assistant",
        content: {
          type: "text",
          text: `I'd be happy to discuss ${topic} with you. What would you like to know?`
        }
      }
    ]
  })
);

Alternatives considered

Decorators, although still not fully standardized in ECMAScript, are at stage 3 and already supported by TypeScript. I believe it would be possible to use them to craft some nice APIs that could be used like so:

class MyServer extends Server {
  @tool
  myCustomTool(someArg: number) {
    return "foobar";
  }
}

However, decorators are too foreign to many JS developers, and unfortunately are not supported on free functions—they can only annotate class members—so would not achieve an idiomatic API quite like FastMCP in the Python SDK.

A decorator-based API might be best as a community extension to the base SDK, which needs to be accessible to as wide an audience as possible.

@jspahrsummers
Copy link
Member Author

jspahrsummers commented Jan 10, 2025

Ready for early review. This needs README updates and probably some other niceties, but would love to get any feedback on the APIs as defined and implemented.

Note that most of the added lines are tests.

@jspahrsummers jspahrsummers marked this pull request as ready for review January 13, 2025 21:09
@jspahrsummers
Copy link
Member Author

jspahrsummers commented Jan 13, 2025

README updated too now. Ready for review.

@anaisbetts
Copy link
Contributor

anaisbetts commented Jan 13, 2025

Rather than using optional parameters, would it be better to pass along an options object? Something like:

server.tool({
  name: "save",
  description: "It saves stuff, ofc",
}, async () => {
  return {
    content: [
      {
        type: "text",
        text: "Saved successfully.",
      },
    ],
  };
});

Having optional parameters might have limiting implications for compatibility in future versions (i.e. adding a new field will be Hard)

@jspahrsummers
Copy link
Member Author

IMO too many named parameters makes the API unreadable, even though they should ostensibly be improving its understandability.

I don't think backward compatibility is a massive concern here, since changes would also have to go through the spec (where it's a much bigger constraint than the SDK itself).

Copy link
Contributor

@jerome3o-anthropic jerome3o-anthropic left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks awesome! very clean. Some questions - one concern about the RFC 6570 encoding of spaces otherwise

Once that's resolved 🚀 ship it 🚀

Copy link
Contributor

@jerome3o-anthropic jerome3o-anthropic left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome

One parting thought - how do progress notifications / resource update notifications fit into this nicely? perhaps this could be added to RequestHandlerExtra in the future? From what I see we can add that after this is merged.

@jspahrsummers
Copy link
Member Author

how do progress notifications / resource update notifications fit into this nicely? perhaps this could be added to RequestHandlerExtra in the future? From what I see we can add that after this is merged.

Yeah, something like this. I agree it can come later.

@achan-godaddy
Copy link

achan-godaddy commented Mar 17, 2025

@jspahrsummers It's your api but tend to differ in my experience that having named parameters makes it harder to read, especially without ide dev hints. When I first looked at this I was wondering what the first two params are because there are overloads. When do I provide a schema and when is it correct to give the chat context?

server.tool("echo", { text: z.string() }, ... 
server.tool(
  "add",
  "Adds two numbers together", ...

The consumer needs to know what the magic of the first two params is, it makes extending it require overloads. React query ,v1-v3 had this concept of a "query key" and query function that was the first parameters and they resolved it by making it a named param because they had overloads that caused issues with typing and knowing which version of the api was being used. I think we can learn from their evolution to named params.

https://tanstack.com/query/latest/docs/framework/react/guides/migrating-to-v5#supports-a-single-signature-one-object

In addition, if we look at the apis that this looks most similar to we can look to ts-rest, and the vercel ai sdk we can see that named params are used. This also makes the rule simpler, instead of "make the first or second param defined and then the second or third param the options object" to be "use named params always" in addition to the points raised by @anaisbetts.

Let me add that this api definitely looks a lot better than before and simpler, it's just that the named params (which can still be supported with another, and hopefully final, overload) could make this api even better 😄

Pizzaface pushed a commit to RewstApp/mcp-inspector that referenced this pull request May 2, 2025
…/ashwin/state

refactor: extract draggable pane and connection logic into hooks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants