Free lesson · GenAI Solutions Architecture

Build 5 enterprise DSPy modules

You will build 5 DSPy modules for enterprise tasks: ContractAnalyzer, TicketClassifier, ReportSummarizer, DataExtractor, ComplianceChecker. Each module defines typed dspy.Signature classes with InputField and OutputField annotations, connects to MCP tools from Ch 8-9 for data access, and uses Anthropic Citations API via citations parameter so every output field traces to a source document passage.

Course: Enterprise LLM Customization · Chapter 17 · DSPy Enterprise Modules

Free to read — no subscription required.

Introduction

Engineers often wire enterprise tools to language models using hand-crafted prompt strings scattered across their codebase — fragile text that drifts out of sync whenever requirements change or a new model is deployed. DSPy Signatures replace those ad-hoc prompts with typed, self-documenting Python classes that compile into consistent, optimizable instructions the framework manages for you. By the end of this lesson, you'll be able to define DSPy Signatures for complex enterprise tasks — contract analysis, ticket classification, report summarization, and more — producing structured outputs that integrate directly with MCP enterprise tools as part of a five-module system.

Key Terminology

  • DSPy Signature — A Python class that inherits from dspy.Signature and declares a language model task's input/output contract as typed class attributes, replacing hand-crafted prompt strings with a structured, framework-managed specification.
  • dspy.InputField — A field descriptor that marks a class attribute as an input to the language model task; the desc argument is injected into the compiled prompt to guide how the model interprets that input.
  • dspy.OutputField — A field descriptor that marks a class attribute as an expected output of the language model task; type annotations (e.g., List[str]) tell DSPy how to deserialize the model's text response into a Python value.
  • Compiled Prompt — The prompt DSPy constructs automatically from a Signature's class docstring and field descriptions; it is managed and optimized by the framework rather than hardcoded by the developer.
  • Vocabulary Constraint — A technique of enumerating allowed output values directly in an OutputField's desc string (e.g., "low, medium, high, or critical") so the model's response is bounded to values downstream systems can consume without post-processing.
  • dspy.Predict — The DSPy module that wraps a Signature and executes it against a configured language model, returning a result object whose attributes correspond to the Signature's declared OutputFields.

Concepts

Loading diagram...

Signatures as Typed Contracts, Not Prompt Strings

Most integrations between enterprise tools and language models rely on prompt strings assembled at call time — template literals, f-strings, or concatenated paragraphs that live in application code and drift whenever requirements or models change. DSPy Signatures invert this: instead of writing instructions, you declare what the task needs as a Python class. The class docstring becomes the task instruction, and each field (annotated with a Python type and a desc string) becomes a named slot in the input/output contract.

This shift matters because DSPy — not your application code — owns the prompt. When you later swap models or run an optimizer, the framework recompiles the prompt from the Signature's declarations rather than requiring you to rewrite strings scattered across the codebase. A Signature is the single source of truth for what a task does and what shape its results take.

Field Descriptions as Prompt Engineering

The desc argument on dspy.InputField and dspy.OutputField is where you do your prompt engineering — but at the field level, not the whole-prompt level. DSPy injects these descriptions into the compiled prompt so the model knows exactly what each slot means. Vague descriptions produce vague outputs; specific descriptions that name expected values, enumerate a vocabulary, or describe the format the model should follow consistently produce more reliable, parseable results.

Vocabulary constraints are the most immediately useful application of this principle. By listing the allowed values in desc — for example, "p0_critical, p1_high, p2_medium, or p3_low" in TicketClassifier.priority — you align the model's output to the tokens your downstream system already understands. The TicketClassifier maps directly to PagerDuty/ServiceNow priority codes; ContractAnalyzer.risk_level maps to a four-value enum a routing rule can branch on. No post-processing translation layer is needed (see Code Walkthrough).

Scaling the Pattern Across Enterprise Tasks

A single Signature can accept multiple inputs to encode task context that changes the semantics of the output. TicketClassifier takes both ticket_text and customer_tier so that tier-based priority logic is part of the model's instruction rather than a conditional applied after the fact. This keeps the business rule — "enterprise customers get elevated priority" — inside the Signature where it can be read, reviewed, and optimized as a unit.

The five enterprise signatures in this module — contract analysis, ticket classification, report summarization, code review, and meeting analysis — all follow the same structural pattern: a docstring that frames the task, typed inputs that supply context, and typed outputs whose descriptions constrain the vocabulary. Once you recognize this pattern, adding a new enterprise task means writing a new Signature class, not auditing prompt strings across files. The dspy.Predict module executes any Signature against the configured model and returns a result object whose attributes match the declared OutputField names exactly.

Code Walkthrough

Now that you understand how DSPy Signatures declare task structure as typed Python classes, let's walk through two of the five enterprise signatures you'll build for this module.

A DSPy Signature inherits from dspy.Signature, uses a class docstring as the task instruction, and declares fields with dspy.InputField and dspy.OutputField. The type annotations tell DSPy how to parse model output — a List[str] annotation is automatically deserialized from the model's text response. More specific field descriptions consistently produce more reliable outputs because DSPy injects those descriptions into the compiled prompt.

The first signature handles legal contract analysis:

Code snippetpython
1import dspy 2from typing import List 3 4class ContractAnalyzer(dspy.Signature): 5 """Analyze a legal contract and extract structured information 6 including all parties, key terms, obligations, and overall risk.""" 7 8 contract_text: str = dspy.InputField( 9 desc="Full text of the legal contract to analyze" 10 ) 11 12 parties: List[str] = dspy.OutputField( 13 desc="All parties named in the contract with their roles" 14 ) 15 key_terms: List[str] = dspy.OutputField( 16 desc="Key contractual terms including duration, renewal, " 17 "termination clauses, and financial terms" 18 ) 19 obligations: List[str] = dspy.OutputField( 20 desc="Specific obligations for each party, including " 21 "deadlines and deliverables" 22 ) 23 risk_level: str = dspy.OutputField( 24 desc="Overall risk assessment: low, medium, high, or critical, " 25 "with a one-sentence justification" 26 )

The docstring acts as the compiled prompt's task instruction — treat it as a system prompt that DSPy owns and optimizes. The risk_level field constrains the model to a fixed vocabulary by naming the allowed values directly in the description; downstream systems can then filter or route contracts by risk without any post-processing step.

The same structural pattern scales to other enterprise tasks. The TicketClassifier signature adds a second input — customer_tier — so the model applies tier-based priority logic alongside ticket content:

Code snippetpython
1class TicketClassifier(dspy.Signature): 2 """Classify a support ticket by category, priority, responsible 3 department, and recommended action.""" 4 5 ticket_text: str = dspy.InputField( 6 desc="Full text of the support ticket including subject and body" 7 ) 8 customer_tier: str = dspy.InputField( 9 desc="Customer tier: free, professional, or enterprise" 10 ) 11 12 category: str = dspy.OutputField( 13 desc="Ticket category: billing, technical, feature_request, " 14 "account, security, or general" 15 ) 16 priority: str = dspy.OutputField( 17 desc="Priority level: p0_critical, p1_high, p2_medium, or p3_low" 18 ) 19 department: str = dspy.OutputField( 20 desc="Responsible department: engineering, support, billing, " 21 "security, or product" 22 ) 23 suggested_action: str = dspy.OutputField( 24 desc="Specific recommended next step for the assigned department" 25 )

The P0–P3 priority naming maps directly to enterprise incident management systems such as PagerDuty and ServiceNow, so the signature's output can route tickets without a translation layer. The remaining three enterprise signatures — for report summarization, code review, and meeting analysis — follow this identical pattern: a descriptive docstring, typed input fields, and output fields whose descriptions constrain the vocabulary or enumerate expected values.

You'll know it works when instantiating each signature class raises no import or type errors and calling dspy.Predict(ContractAnalyzer)(contract_text=sample_text) returns a result object with all four declared output fields populated and non-empty.

Do's and Don'ts

Having walked through the material above, the following Do's and Don'ts distill it into practice.

Do's

  1. Do write dspy.OutputField descriptions that enumerate the exact allowed vocabulary — naming values like "p0_critical, p1_high, p2_medium, or p3_low" directly in the desc string constrains model output to a fixed set that downstream systems (PagerDuty, ServiceNow) can consume without a translation layer.
  2. Do use typed annotations such as List[str] on output fields when a task produces multiple items — DSPy uses the annotation to automatically deserialize the model's text response into a Python list, so parties and key_terms arrive as structured data rather than a raw string you must parse yourself.
  3. Do treat the class docstring as the compiled prompt's task instruction — DSPy injects it as the system-level directive when it compiles the signature, so a precise docstring like "Analyze a legal contract and extract structured information including all parties, key terms, obligations, and overall risk" produces more reliable outputs than a vague one-liner.

Don'ts

  1. Don't omit the desc argument from InputField or OutputField declarations — DSPy injects field descriptions into the compiled prompt, and a missing or generic desc gives the optimizer nothing to work with, producing inconsistent outputs especially for multi-input signatures like TicketClassifier where customer_tier must signal tier-based priority logic to the model.
  2. Don't scatter task instructions across ad-hoc prompt strings outside the dspy.Signature class — the whole point of the signature pattern is that DSPy owns and optimizes the prompt; instructions that live outside the docstring or field descriptions are invisible to the compiler and will drift out of sync when a new model is deployed.
  3. Don't skip verifying that every declared output field is populated after a dspy.Predict call — calling dspy.Predict(ContractAnalyzer)(contract_text=sample_text) and checking that all four fields (parties, key_terms, obligations, risk_level) are non-empty is the minimum smoke test; a field that silently returns an empty string means the description failed to constrain the model and the MCP tool receiving that field will get corrupt input.

Connect DSPy to MCP Tools

Introduction

Engineers often need their DSPy modules to pull live enterprise data — contracts from a document store, tickets from a service desk, compliance regulations from a policy database — but wiring those external systems into a DSPy execution pipeline requires a standardized connection layer. The Model Context Protocol (MCP) provides exactly that: a transport-agnostic client-server interface that lets a DSPy module discover and invoke named tools at runtime without coupling itself to any particular backend. By the end of this lesson, you will be able to register MCP tool servers, open managed async sessions, and call named tools from inside a DSPy module so that enterprise data flows cleanly into your Signature inputs.

Key Terminology

  • Model Context Protocol (MCP) — A transport-agnostic client-server specification that lets a DSPy module discover and invoke named tools on remote enterprise systems at runtime without coupling the module to any particular backend SDK or API.
  • StdioServerParameters — A configuration object that bundles the shell command, args, and optional env needed to launch an MCP tool server as a subprocess over stdio transport; stored in MCPToolConnector.server_params during registration and consumed only when a session is actually opened.
  • ClientSession — The live MCP session object, instantiated via ClientSession(read, write) after session.initialize() succeeds, through which all call_tool dispatches travel; stored in self.sessions for the duration of the async with block and deleted in the finally clause when the block exits.
  • Stdio transport — The MCP connection mechanism used in this lesson, where stdio_client launches the server as a subprocess and exchanges protocol messages over its standard input/output streams, making the server launch parameters (command, args) the only backend-specific configuration.
  • Registration-connection separation — The design pattern in MCPToolConnector where register_server records server parameters at startup with no network activity, and connect opens the actual subprocess session on demand, so a pipeline can declare all enterprise servers upfront but pay the connection cost only when a specific module's forward method needs one.
  • Async context manager — A resource-lifecycle construct (decorated with @asynccontextmanager) that MCPToolConnector.connect uses to guarantee the ClientSession is fully initialized before yield and unconditionally removed from self.sessions in the finally block, preventing stale session references whether the enclosed body succeeds or raises.

Concepts

Loading diagram...

MCP as a Decoupling Layer for Enterprise Tools

When a DSPy module needs live data — a contract from a document store, a ticket from a service desk, a compliance rule from a policy database — the tempting shortcut is to embed the fetching logic directly: import the backend SDK, manage credentials, call the API. The problem is coupling: every backend change forces a change inside the module, and every new data source demands a new integration pattern.

The Model Context Protocol resolves this by placing a uniform client-server interface between the module and any data source. An MCP server wraps a backend and exposes it as a set of named tools. The DSPy module talks only to the MCP client layer — it calls a tool by name with a dict of arguments and receives a structured response, completely independent of what runs on the server side. This is what the introduction means by "transport-agnostic": the module code is identical whether the server is a local subprocess, a remote socket, or a cloud function. The lesson uses the stdio transport, where the client spawns the server as a subprocess and routes messages through its stdin/stdout, but nothing in ContractAnalyzer encodes that choice.

Separating Registration from Connection

MCPToolConnector enforces a two-phase lifecycle. During startup, register_server records a StdioServerParameters entry in self.server_params — no subprocess is spawned, no session is opened. The actual connection happens inside a specific forward call, when connect is entered as an async context manager.

This separation matters in production pipelines for two reasons. First, a pipeline may declare many tool servers but only exercise a subset in any given invocation; paying connection cost only on demand keeps idle servers offline and avoids unnecessary subprocess churn. Second, the finally clause in connect deletes the ClientSession from self.sessions the moment the async with block exits, so there is no risk of a stale or half-open session persisting across calls. Registration is declaration; connection is execution — keeping them distinct makes the lifetime of each session visible and predictable (see Code Walkthrough).

Bridging Async MCP Sessions into Synchronous DSPy Modules

MCP session operations are inherently asynchronous — connect, initialize, and call_tool are all coroutines. DSPy's Module.forward, by contrast, is a synchronous method that DSPy's optimizers, predictors, and composition utilities expect to call with ordinary function semantics. These two contracts appear incompatible, but the bridge is straightforward: asyncio.run(coroutine) creates an event loop, drives the coroutine to completion, and returns its result synchronously.

In ContractAnalyzer.forward, all async work — opening the session, dispatching get_contract, unpacking the response — is collected inside a local async def _fetch()closure.asyncio.run(_fetch())drives it to completion before the Signature receivescontract_text. From DSPy's perspective, forwardis a normal synchronous function that returns adspy.Prediction; the asyncMCP machinery is an implementation detail sealed inside the closure. This pattern — localasyncclosure, oneasyncio.run` call, result passed to the Signature — is the reusable template for every DSPy module that needs to reach an MCP tool server.

Code Walkthrough

Now that you understand the MCP client-server model and DSPy's Signature and Module abstractions, you can see how they join together in a single connection layer.

The MCPToolConnector class manages that bridge. It holds a registry of known tool servers, each identified by a name, a launch command, and startup arguments. Three methods carry all the weight: register_server stores connection parameters without opening any network connection, connect opens an stdio-transport session and yields it as a context manager, and call_tool dispatches a named tool invocation to a live session. Separating registration from connection means you can declare every enterprise server your pipeline might need at startup, then open only the ones a specific module requires during execution.

Code snippetpython
1import asyncio 2from mcp import ClientSession, StdioServerParameters 3from mcp.client.stdio import stdio_client 4from contextlib import asynccontextmanager 5from typing import Any, Dict, Optional 6 7class MCPToolConnector: 8 """Connects DSPy modules to MCP tool servers for enterprise data retrieval.""" 9 10 def __init__(self): 11 self.sessions: Dict[str, ClientSession] = {} 12 self.server_params: Dict[str, StdioServerParameters] = {} 13 14 def register_server( 15 self, name: str, command: str, args: list[str], 16 env: Optional[Dict[str, str]] = None, 17 ): 18 self.server_params[name] = StdioServerParameters( 19 command=command, args=args, env=env, 20 ) 21 22 @asynccontextmanager 23 async def connect(self, server_name: str): 24 params = self.server_params[server_name] 25 async with stdio_client(params) as (read, write): 26 async with ClientSession(read, write) as session: 27 await session.initialize() 28 self.sessions[server_name] = session 29 try: 30 yield session 31 finally: 32 del self.sessions[server_name] 33 34 async def call_tool( 35 self, server_name: str, tool_name: str, arguments: Dict[str, Any], 36 ) -> Any: 37 session = self.sessions.get(server_name) 38 if session is None: 39 raise ConnectionError(f"Not connected to server: {server_name}") 40 return await session.call_tool(tool_name, arguments)

With the connector defined, you inject it into a DSPy module at construction time and call it inside forward. The module below fetches a contract document from a compliance tool server, then passes the retrieved text directly to its ChainOfThought Signature for analysis. The asyncio.run call bridges DSPy's synchronous forward interface to the async MCP session.

Code snippetpython
1import asyncio 2import dspy 3from mcp_connector import MCPToolConnector 4 5class ContractAnalyzer(dspy.Module): 6 def __init__(self, connector: MCPToolConnector): 7 super().__init__() 8 self.connector = connector 9 self.analyze = dspy.ChainOfThought( 10 "contract_text -> risk_summary, key_clauses" 11 ) 12 13 def forward(self, contract_id: str) -> dspy.Prediction: 14 async def _fetch(): 15 async with self.connector.connect("compliance-server"): 16 return await self.connector.call_tool( 17 "compliance-server", 18 "get_contract", 19 {"id": contract_id}, 20 ) 21 result = asyncio.run(_fetch()) 22 contract_text = result.content[0].text 23 return self.analyze(contract_text=contract_text)

Confirm that calling ContractAnalyzer(connector).forward("CONTRACT-001") returns a dspy.Prediction with non-empty risk_summary and key_clauses fields and raises no ConnectionError — that verifies the session lifecycle, tool dispatch, and Signature wiring are all functioning correctly end-to-end.

Do's and Don'ts

Having walked through the material above, the following Do's and Don'ts distill it into practice.

Do's

  1. Do call register_server for every enterprise tool server at pipeline startup, before any module's forward runs — separating registration from connection means StdioServerParameters are declared once while stdio transport sessions are opened only for the specific servers a given module needs, avoiding unnecessary subprocess launches across the whole pipeline.
  2. Do wrap every call_tool invocation inside connector.connect(server_name) as an async context manager — the connect method's finally block deletes the session from self.sessions on exit, so any call_tool call made outside that scope finds no entry in the sessions dict and immediately raises ConnectionError: Not connected to server.
  3. Do bridge DSPy's synchronous forward interface to async MCP calls using asyncio.run(_fetch()) — DSPy's optimizer invokes forward synchronously, so any bare await session.call_tool(...) inside forward raises a RuntimeError about no running event loop; the nested async closure pattern is the correct shim.

Don'ts

  1. Don't skip await session.initialize() after constructing a ClientSessioninitialize() completes the MCP capability handshake; tool dispatches sent before it either hang indefinitely or return protocol errors because the server has not yet confirmed which tools it exposes.
  2. Don't pass the raw MCP tool result object directly into a DSPy Signature field — the response is a structured object whose text payload lives at result.content[0].text; passing the object itself causes a type mismatch that the Signature's string field cannot process, silently producing empty or corrupted ChainOfThought inputs.
  3. Don't cache a ClientSession reference beyond the connect context manager's scope — the connector's sessions dict is managed by the context manager's finally block, and the underlying stdio transport closes when connect exits; holding a stale handle and calling call_tool against it on a subsequent forward invocation produces silent failures with no ConnectionError to surface the problem.

This lesson is free to read. Its hands-on lab — real code, in a cloud IDE — is part of the GenAI Solutions Architecture subscription.

From · cancel anytime · Already a subscriber? Sign in →

More free lessons in Enterprise LLM Customization

All free lessons in GenAI Solutions Architecture