I create modern web applications and custom digital tools to help businesses grow through technological innovation. My passion is combining computer science and economics to generate real value.
My passion for computer science was born at the Technical Commercial Institute of Maglie, where I discovered the power of programming and the fascination of creating digital solutions. From the start, I understood that computer science was not just code, but an extraordinary tool for turning ideas into reality.
During my studies in Business Information Systems, I began to interweave computer science and economics, understanding how technology can be the engine of growth for any business. This vision accompanied me to the University of Bari, where I obtained my degree in Computer Science, deepening my technical skills and passion for software development.
Today I put this experience at the service of businesses, professionals and startups, creating tailor-made digital solutions that automate processes, optimize resources and open new business opportunities. Because true innovation begins when technology meets the real needs of people.
My Skills
Data Analysis & Predictive Models
I transform data into strategic insights with in-depth analysis and predictive models for informed decisions
Process Automation
I create custom tools that automate repetitive operations and free up time for value-added activities
Custom Systems
I develop tailor-made software systems, from platform integrations to customized dashboards
Credo fermamente che l'informatica sia lo strumento più potente per trasformare le idee in realtà e migliorare la vita delle persone.
Democratizzare la Tecnologia
La mia missione è rendere l'informatica accessibile a tutti: dalle piccole imprese locali alle startup innovative, fino ai professionisti che vogliono digitalizzare la propria attività. Ogni realtà merita di sfruttare le potenzialità del digitale.
Unire Informatica ed Economia
Non è solo questione di scrivere codice: è capire come la tecnologia possa generare valore reale. Intrecciando competenze informatiche e visione economica, aiuto le attività a crescere, ottimizzare processi e raggiungere nuovi traguardi di efficienza e redditività.
Creare Soluzioni su Misura
Ogni attività è unica, e così devono esserlo le soluzioni. Sviluppo strumenti personalizzati che rispondono alle esigenze specifiche di ciascun cliente, automatizzando processi ripetitivi e liberando tempo per ciò che conta davvero: far crescere il business.
Trasforma la Tua Attività con la Tecnologia
Che tu gestisca un negozio, uno studio professionale o un'azienda, posso aiutarti a sfruttare le potenzialità dell'informatica per lavorare meglio, più velocemente e in modo più intelligente.
Bari, Puglia, Italy · Hybrid
Analysis and development of computer systems through the use of Java and Quarkus in Health and Public Sector. Continuous training on modern technologies for creating customized and efficient software solutions and on agents.
💼
06/2022 - 12/2024
Software analyst and Back End Developer Associate Consultant
Links Management and Technology SpA
Experience analyzing as-is software systems and ETL flows using PowerCenter. Completed Spring Boot training for developing modern and scalable backend applications. Backend developer specialized in Spring Boot, with experience in database design, analysis, development and testing of assigned tasks.
💼
02/2021 - 10/2021
Software programmer
Adesso.it (prima era WebScience srl)
Experience in AS-IS and TO-BE analysis, SEO evolutions and website evolutions to improve user performance and engagement.
🎓
2018 - 2025
Degree in Computer Science
University of Bari Aldo Moro
Bachelor's degree in Computer Science, focusing on software engineering, algorithms, and modern development practices.
📚
2013 - 2018
Diploma - Corporate Information Systems
Technical Commercial Institute of Maglie
Technical diploma specializing in Business Information Systems, combining IT knowledge with business management.
Contattami
Hai un progetto in mente? Parliamone! Compila il form qui sotto e ti risponderò al più presto.
* Campi obbligatori. I tuoi dati saranno utilizzati solo per rispondere alla tua richiesta.
Introduction: From Protocol to Code
In the previous articles, we explored the fundamentals of the Model Context Protocol, the three
primitives (Tools, Resources, Prompts), and the monorepo architecture. Now it's time to write code:
in this tutorial we'll build a complete MCP server in TypeScript, starting from an empty project
all the way to a working server testable with Claude Desktop.
The server we'll create manages an in-memory notes system, exposing tools to add,
read, search, and delete notes. We'll implement advanced validation with Zod,
structured error handling, and a resource for accessing notes via URI. All code is available
in the Tech-MCP repository on GitHub.
What You'll Learn in This Article
How to set up a TypeScript project for an MCP server (package.json, tsconfig.json)
How to create a server with the official SDK's McpServer class
How to register tools with server.tool() and validate input with Zod schemas
How to expose resources with server.resource() and URI templates
How to handle errors with the isError: true pattern
How to configure the StdioServerTransport transport
How to test the server with MCP Inspector and Claude Desktop
Debugging rules: why you should never use console.log()
Prerequisites
Before starting, make sure you have the following tools installed on your system:
Node.js 18+: JavaScript/TypeScript runtime (node --version to verify)
npm or pnpm: package manager for dependency management
TypeScript 5.x: the typed language we'll use throughout the server
Claude Desktop (optional): to test the server with a real MCP client
Step 1: Project Initialization
Let's create the project structure from scratch. Our server will be called mcp-notes-server
and will be a Node.js project with TypeScript.
Directory Creation and Initialization
# Create the project directory
mkdir mcp-notes-server && cd mcp-notes-server
# Initialize the Node.js project
npm init -y
# Install production dependencies
npm install @modelcontextprotocol/sdk zod
# Install development dependencies
npm install -D typescript @types/node
# Create the source code directory
mkdir -p src
The installed dependencies serve specific roles:
@modelcontextprotocol/sdk: the official MCP SDK providing McpServer, transports, and protocol utilities
zod: schema validation library that MCP uses to define and validate tool parameters
typescript: TypeScript compiler for static typing
@types/node: type definitions for Node.js APIs
package.json Configuration
Update the generated package.json with the configuration needed for an MCP server.
The "type": "module" field is essential because the MCP SDK uses ESM (ECMAScript Modules):
The "type": "module" field is mandatory. Without it, Node.js will treat .js
files as CommonJS and the MCP SDK import will fail with a syntax error. If you forget this field,
you'll see an error like SyntaxError: Cannot use import statement in a module.
TypeScript Configuration (tsconfig.json)
Create the tsconfig.json file in the project root. The configuration must be compatible
with ESM and Node.js 16+:
target: "ES2022": compiles to a modern JavaScript version with top-level await support
module: "Node16": generates modules compatible with Node.js 16+ ESM system
moduleResolution: "Node16": resolves modules following Node.js 16 rules (requires .js extensions in imports)
strict: true: enables all strict type checking for greater safety
declaration: true: generates .d.ts files for external type checking
Final Project Structure
After setup, the project structure will look like this:
mcp-notes-server/
src/
index.ts # Entry point and server logic
package.json # npm configuration with type: module
tsconfig.json # TypeScript configuration
node_modules/ # Installed dependencies
Step 2: Creating the Minimal MCP Server
Let's start with a minimal server that registers a single tool. Create the file src/index.ts:
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
// ============================================
// In-memory state
// ============================================
const notes: Map<string, string> = new Map();
// ============================================
// MCP Server creation
// ============================================
const server = new McpServer({
name: "notes-server",
version: "1.0.0",
});
// ============================================
// Tool: add a note
// ============================================
server.tool(
"add-note",
"Adds a new note with a title and content",
{
title: z.string().describe("Title of the note"),
content: z.string().describe("Content of the note"),
},
async ({ title, content }) => {
notes.set(title, content);
return {
content: [
{
type: "text",
text: `Note "
#123;title}" saved successfully.`,
},
],
};
},
);
// ============================================
// Start server with STDIO transport
// ============================================
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Notes MCP Server started on stdio");
}
main().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});
Code Anatomy
Let's analyze each section of the code to understand the role of each component.
1. The Shebang and Imports
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
The shebang (#!/usr/bin/env node) allows executing the file directly as
a script from the command line. The imports load three essential components:
McpServer: the main class that manages the protocol lifecycle, tool registration, and capability negotiation
StdioServerTransport: the transport that communicates via the process stdin/stdout
z (Zod): the validation library for defining parameter schemas
2. Creating the Server Instance
const server = new McpServer({
name: "notes-server", // Unique server identifier
version: "1.0.0", // Version following semver format
});
The McpServer instance accepts a configuration object with name (unique
identifier the client uses to recognize the server) and version (the version in semver format).
This data is sent to the client during the protocol initialization phase.
3. The server.tool() Signature
The server.tool() method is the core of tool registration. It accepts four arguments:
server.tool() Arguments
Argument
Type
Description
name
string
Unique tool identifier (e.g., "add-note")
description
string
Description read by the AI model to decide when to invoke the tool
inputSchema
object
Object with Zod keys defining the accepted parameters
handler
async function
Async function receiving typed parameters and returning the result
4. The Result Format
Every tool handler must return an object with a content field, which is an array of
typed elements. MCP supports three content types in the result:
const transport = new StdioServerTransport();
await server.connect(transport);
StdioServerTransport configures the server to communicate via stdin (receiving messages from the client)
and stdout (sending responses to the client). The connect() method starts the
read/write loop and from that point the server is listening for JSON-RPC messages.
Fundamental Rule: Never Write to stdout
With the STDIO transport, the stdout channel is reserved exclusively for JSON-RPC
protocol messages. Any other output to stdout (like a console.log()) would corrupt
the protocol and cause parsing errors in the client. For logging and debugging, always
use console.error(), which writes to stderr.
Step 3: Adding Complete Tools (CRUD)
Let's expand the server with a complete set of CRUD operations on notes. Add the following tools
after the add-note tool in the src/index.ts file:
Tool: Reading a Note
server.tool(
"get-note",
"Retrieves the content of a note by title",
{
title: z.string().describe("Title of the note to read"),
},
async ({ title }) => {
const content = notes.get(title);
if (!content) {
return {
content: [{ type: "text", text: `Note "#123;title}" not found.` }],
isError: true,
};
}
return {
content: [{ type: "text", text: content }],
};
},
);
Tool: Listing All Notes
server.tool(
"list-notes",
"Lists all saved notes with their titles",
{}, // No parameters required: empty schema
async () => {
if (notes.size === 0) {
return {
content: [{ type: "text", text: "No notes saved." }],
};
}
const list = Array.from(notes.entries())
.map(([title, content], i) =>
`#123;i + 1}. #123;title} (#123;content.length} characters)`
)
.join("\n");
return {
content: [{ type: "text", text: `Saved notes:\n#123;list}` }],
};
},
);
Tool: Deleting a Note
server.tool(
"delete-note",
"Deletes an existing note by title",
{
title: z.string().describe("Title of the note to delete"),
},
async ({ title }) => {
const deleted = notes.delete(title);
if (!deleted) {
return {
content: [{ type: "text", text: `Note "#123;title}" not found.` }],
isError: true,
};
}
return {
content: [{ type: "text", text: `Note "#123;title}" deleted successfully.` }],
};
},
);
Step 4: Advanced Validation with Zod
Zod is not just a validation library: it's the language you use to describe your tool parameters
to the AI model. Every Zod schema is converted to JSON Schema and sent to the client during the
discovery phase (tools/list). The .describe() method is particularly important
because the text is included in the schema and helps the AI understand what to provide as an argument.
Let's add a search tool with advanced validation to demonstrate Zod's capabilities:
server.tool(
"search-notes",
"Search notes by keyword with advanced filter options",
{
query: z.string()
.min(2)
.describe("Text to search for (minimum 2 characters)"),
caseSensitive: z.boolean()
.optional()
.default(false)
.describe("If true, the search is case-sensitive"),
limit: z.number()
.int()
.min(1)
.max(100)
.optional()
.default(10)
.describe("Maximum number of results (1-100)"),
},
async ({ query, caseSensitive, limit }) => {
const results: string[] = [];
for (const [title, content] of notes) {
const haystack = caseSensitive ? content : content.toLowerCase();
const needle = caseSensitive ? query : query.toLowerCase();
if (haystack.includes(needle)) {
results.push(title);
}
if (results.length >= limit) break;
}
return {
content: [{
type: "text",
text: results.length > 0
? `Found #123;results.length} notes:\n#123;results.join("\n")}`
: `No results for "#123;query}".`,
}],
};
},
);
Common Zod Patterns for MCP
Here is a quick reference of the most commonly used Zod patterns when defining MCP tool schemas:
Zod Quick Reference for MCP
Pattern
Code
Usage
Required string
z.string()
Required text parameter
Non-empty string
z.string().min(1)
Parameter that cannot be empty
Valid email
z.string().email()
Email format validation
Valid URL
z.string().url()
URL format validation
Positive integer
z.number().int().positive()
IDs, counters
Numeric range
z.number().min(0).max(100)
Percentages, scores
Boolean
z.boolean()
On/off flags
String enum
z.enum(["low", "medium", "high"])
Predefined values
Optional with default
z.string().optional().default("value")
Parameters with default values
String array
z.array(z.string())
Lists, tags
Description for AI
z.string().describe("explanation")
Guides the AI model in filling values
The .describe() method is crucial for the AI experience: the text is included in the
JSON schema sent to the model during discovery, allowing it to understand exactly what value to provide
for each parameter. Write clear and concise descriptions.
Step 5: Error Handling with isError
MCP defines a clear pattern for error handling at the tool level. There are two categories
of errors to handle:
Execution Errors (Handled by the Tool)
When a tool encounters a predictable error (resource not found, validation failed, service unavailable),
it should return a result with isError: true. This signals to the AI model that the
operation has failed, allowing it to react appropriately:
server.tool(
"get-note",
"Retrieves a note by title",
{
title: z.string().describe("Title of the note"),
},
async ({ title }) => {
try {
const content = notes.get(title);
if (!content) {
// Predictable error: note not found
return {
content: [{
type: "text",
text: `Error: note "#123;title}" does not exist.`,
}],
isError: true,
};
}
return {
content: [{ type: "text", text: content }],
};
} catch (error) {
// Unexpected error: caught and handled
return {
content: [{
type: "text",
text: `Error: #123;error instanceof Error ? error.message : String(error)}`,
}],
isError: true,
};
}
},
);
Protocol Errors (Unhandled Exceptions)
If a tool handler throws an unhandled exception, the SDK automatically transforms it into a
JSON-RPC error with code -32603 (Internal Error). It's good practice to always handle
errors explicitly with try/catch to provide more informative error messages to the model.
Best Practice: Always Handle Errors
The recommended pattern is to wrap all tool logic in a try/catch block,
returning isError: true with a descriptive message on error. This allows
the AI model to receive the error as context and decide how to proceed (retry,
ask the user, or try a different approach).
Step 6: Exposing Resources with server.resource()
Beyond tools, an MCP server can expose resources: contextual data accessible by the
client via URI. Resources are useful for providing read-only information without requiring
an explicit action from the model.
Let's add a resource that exposes the complete list of notes:
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
// Resource: complete notes list
server.resource(
"notes-list",
"note://list",
"Complete list of all saved notes",
async (uri) => {
const allNotes = Array.from(notes.entries())
.map(([title, content]) => `## #123;title}\n#123;content}`)
.join("\n\n---\n\n");
return {
contents: [
{
uri: uri.href,
mimeType: "text/markdown",
text: allNotes || "No notes available.",
},
],
};
},
);
// Resource with URI template: single note
server.resource(
"note-by-title",
new ResourceTemplate("note://{title}", { list: undefined }),
"Access a single note by title",
async (uri, { title }) => {
const content = notes.get(title as string);
return {
contents: [
{
uri: uri.href,
mimeType: "text/plain",
text: content ?? `Note "#123;title}" not found.`,
},
],
};
},
);
The difference between tools and resources is fundamental in the MCP protocol:
Tools: actions invoked by the AI model, can have side effects (create, modify, delete data)
Resources: read-only data accessible by the client application, no side effects
Step 7: The Complete Server
Here is the complete server code with all tools, resources, and error handling.
This is the final src/index.ts file:
#!/usr/bin/env node
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
// ============================================
// In-memory state
// ============================================
const notes: Map<string, string> = new Map();
// ============================================
// MCP Server creation
// ============================================
const server = new McpServer({
name: "notes-server",
version: "1.0.0",
});
// ============================================
// TOOL: Add a note
// ============================================
server.tool(
"add-note",
"Adds a new note with title and content",
{
title: z.string().min(1).describe("Title of the note"),
content: z.string().min(1).describe("Content of the note"),
},
async ({ title, content }) => {
if (notes.has(title)) {
return {
content: [{
type: "text",
text: `Note "#123;title}" already exists. Use a different title.`,
}],
isError: true,
};
}
notes.set(title, content);
return {
content: [{
type: "text",
text: `Note "#123;title}" saved successfully (#123;content.length} characters).`,
}],
};
},
);
// ============================================
// TOOL: Read a note
// ============================================
server.tool(
"get-note",
"Retrieves the content of a note by title",
{
title: z.string().describe("Title of the note to read"),
},
async ({ title }) => {
const content = notes.get(title);
if (!content) {
return {
content: [{ type: "text", text: `Note "#123;title}" not found.` }],
isError: true,
};
}
return {
content: [{ type: "text", text: content }],
};
},
);
// ============================================
// TOOL: List all notes
// ============================================
server.tool(
"list-notes",
"Lists all saved notes with their titles",
{},
async () => {
if (notes.size === 0) {
return {
content: [{ type: "text", text: "No notes saved." }],
};
}
const list = Array.from(notes.entries())
.map(([title, content], i) =>
`#123;i + 1}. #123;title} (#123;content.length} characters)`
)
.join("\n");
return {
content: [{ type: "text", text: `Saved notes:\n#123;list}` }],
};
},
);
// ============================================
// TOOL: Delete a note
// ============================================
server.tool(
"delete-note",
"Deletes an existing note by title",
{
title: z.string().describe("Title of the note to delete"),
},
async ({ title }) => {
const deleted = notes.delete(title);
if (!deleted) {
return {
content: [{ type: "text", text: `Note "#123;title}" not found.` }],
isError: true,
};
}
return {
content: [{ type: "text", text: `Note "#123;title}" deleted.` }],
};
},
);
// ============================================
// TOOL: Search notes with advanced filters
// ============================================
server.tool(
"search-notes",
"Search notes by keyword with filter options",
{
query: z.string().min(2).describe("Text to search for (minimum 2 characters)"),
caseSensitive: z.boolean().optional().default(false)
.describe("If true, the search is case-sensitive"),
limit: z.number().int().min(1).max(100).optional().default(10)
.describe("Maximum number of results (1-100)"),
},
async ({ query, caseSensitive, limit }) => {
const results: string[] = [];
for (const [title, content] of notes) {
const haystack = caseSensitive ? content : content.toLowerCase();
const needle = caseSensitive ? query : query.toLowerCase();
if (haystack.includes(needle)) {
results.push(title);
}
if (results.length >= limit) break;
}
return {
content: [{
type: "text",
text: results.length > 0
? `Found #123;results.length} notes:\n#123;results.join("\n")}`
: `No results for "#123;query}".`,
}],
};
},
);
// ============================================
// RESOURCE: Complete notes list
// ============================================
server.resource(
"notes-list",
"note://list",
"Complete list of all saved notes",
async (uri) => {
const allNotes = Array.from(notes.entries())
.map(([title, content]) => `## #123;title}\n#123;content}`)
.join("\n\n---\n\n");
return {
contents: [{
uri: uri.href,
mimeType: "text/markdown",
text: allNotes || "No notes available.",
}],
};
},
);
// ============================================
// Start server with STDIO transport
// ============================================
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Notes MCP Server started on stdio");
}
main().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});
Step 8: Build and Test with MCP Inspector
Compile the project and test the server with MCP Inspector, the official debugging tool for MCP servers:
# Compile the TypeScript project
npm run build
# Test with MCP Inspector (opens a web interface)
npx @modelcontextprotocol/inspector dist/index.js
MCP Inspector opens a web interface in the browser where you can:
View all registered tools with their schemas
Invoke tools manually providing test parameters
Verify the response format
Test resources via URI
Monitor the JSON-RPC messages being exchanged
Step 9: Claude Desktop Configuration
To use the server with Claude Desktop (the most popular MCP client), add the configuration
in the appropriate file for your operating system:
After saving the file and restarting Claude Desktop, the model will see the notes server tools
and can invoke them automatically during conversations. You can verify the server is
connected correctly by looking for the tools icon in Claude Desktop's bottom bar.
Adding Environment Variables
If your server needs environment variables (for example for API keys or configuration),
you can add them in the configuration with the env field:
Debugging an MCP server presents unique challenges compared to traditional applications.
Here are the fundamental rules and tips for solving the most common issues:
Rule 1: Never Use console.log()
With the STDIO transport, console.log() writes to stdout, which is the channel
reserved for JSON-RPC messages. A single console.log() can corrupt the entire
protocol. Always use console.error() for debugging:
// WRONG - corrupts the STDIO protocol
console.log("Debug: note added");
// CORRECT - writes to stderr, no interference
console.error("Debug: note added");
console.error("[DEBUG]", JSON.stringify({ title, content }));
Rule 2: Verify the Build
A common mistake is forgetting to recompile after changes. Use npm run dev
for automatic compilation in watch mode:
# Automatic compilation on every change
npm run dev
# In another terminal, test with the inspector
npx @modelcontextprotocol/inspector dist/index.js
Rule 3: Check Server Logs
Messages written to stderr are visible in Claude Desktop's terminal
(Developer > Open Console) and in MCP Inspector. Use structured logs to facilitate debugging:
Error handling: the isError: true pattern for managed errors and try/catch for unexpected exceptions
server.resource(): exposing contextual data via URI and URI templates
StdioServerTransport: configuring the transport for stdin/stdout communication
Testing: verification with MCP Inspector and Claude Desktop configuration
Debugging: the fundamental rule of never writing to stdout, using console.error() for logs
Next Article
In the next article of the series, we'll tackle the other side of the protocol: creating an MCP Client
in TypeScript. We'll see how to connect to a server, negotiate capabilities, list and invoke
tools programmatically, and handle the HTTP transport for remote connections. We'll learn to build
a client that can communicate with any MCP server compatible with the standard protocol.