Skip to content

Simplified, Express-like API - #117

Merged
jspahrsummers merged 33 commits into
mainfrom
justin/simplified-api
Jan 20, 2025
Merged

jspahrsummers merged 33 commits into
mainfrom
justin/simplified-api

Conversation

@jspahrsummers

@jspahrsummers jspahrsummers commented Jan 7, 2025 •

Copy link
Copy Markdown
Member

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.

Comment thread src/shared/uriTemplate.ts
return expr
.slice(operator.length)
.split(",")
.map((name) => name.replace("*", "").trim())

Check failure

Code scanning / CodeQL

Incomplete string escaping or encoding

This replaces only the first occurrence of "*".

Copilot Autofix

AI almost 2 years ago

To fix the problem, we need to ensure that all occurrences of the asterisk character are removed from the name string. This can be achieved by using a regular expression with the global flag (g) in the replace method. This change will ensure that every asterisk in the string is replaced, not just the first one.

Suggested changeset 1
src/shared/uriTemplate.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/shared/uriTemplate.ts b/src/shared/uriTemplate.ts
--- a/src/shared/uriTemplate.ts
+++ b/src/shared/uriTemplate.ts
@@ -118,3 +118,3 @@
       .split(",")
-      .map((name) => name.replace("*", "").trim())
+      .map((name) => name.replace(/\*/g, "").trim())
       .filter((name) => name.length > 0);
EOF
@@ -118,3 +118,3 @@
.split(",")
.map((name) => name.replace("*", "").trim())
.map((name) => name.replace(/\*/g, "").trim())
.filter((name) => name.length > 0);
Copilot is powered by AI and may make mistakes. Always verify output.
Comment thread src/shared/uriTemplate.ts
for (let i = 0; i < names.length; i++) {
const { name, exploded } = names[i];
const value = match[i + 1];
const cleanName = name.replace("*", "");

Check failure

Code scanning / CodeQL

Incomplete string escaping or encoding

This replaces only the first occurrence of "*".

Copilot Autofix

AI almost 2 years ago

To fix the problem, we need to ensure that all occurrences of the asterisk (*) character in the name string are replaced. This can be achieved by using a regular expression with the global flag (g). Specifically, we should replace name.replace("*", "") with name.replace(/\*/g, "").

Suggested changeset 1
src/shared/uriTemplate.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/shared/uriTemplate.ts b/src/shared/uriTemplate.ts
--- a/src/shared/uriTemplate.ts
+++ b/src/shared/uriTemplate.ts
@@ -300,3 +300,3 @@
       const value = match[i + 1];
-      const cleanName = name.replace("*", "");
+      const cleanName = name.replace(/\*/g, "");
 
EOF
@@ -300,3 +300,3 @@
const value = match[i + 1];
const cleanName = name.replace("*", "");
const cleanName = name.replace(/\*/g, "");

Copilot is powered by AI and may make mistakes. Always verify output.
@jspahrsummers

jspahrsummers commented Jan 10, 2025 •

Copy link
Copy Markdown
Member Author

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

jspahrsummers commented Jan 13, 2025 •

Copy link
Copy Markdown
Member Author

README updated too now. Ready for review.

@anaisbetts

anaisbetts commented Jan 13, 2025 •

Copy link
Copy Markdown
Contributor

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
Copy Markdown
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).

@jerome3o-anthropic jerome3o-anthropic left a comment

Copy link
Copy Markdown
Member

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 🚀

Comment thread README.md Resolved
Comment thread src/shared/uriTemplate.test.ts Resolved
Comment thread src/server/mcp.ts Resolved
Comment thread src/server/mcp.ts Resolved

@jerome3o-anthropic jerome3o-anthropic left a comment

Copy link
Copy Markdown
Member

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
Copy Markdown
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

achan-godaddy commented Mar 17, 2025 •

Copy link
Copy Markdown

@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
mattgrid added a commit to GRID-is/claude-mcp that referenced this pull request May 20, 2025
Introduced a few months ago in v1.3.0 of the MCP SDK.

modelcontextprotocol/typescript-sdk#117
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.

5 participants