← Builder Path / Chapter 13
Architecture position

Capability Extensions

Extend what an agent can do through capability packages, external evidence, persistent state, and standard protocols.

How do we add Skills, retrieval, memory, and external protocol capabilities to an agent?

Concept calibration

MCP

What it is
A standard protocol for connecting tools, resources, and prompts over local or remote transports.
What it is not
It is not a remote API, an agent, or the only way an agent can access tools.

Tool

What it is
A constrained capability an agent can invoke through explicit arguments, permissions, and result contracts.
What it is not
A tool is not the same as MCP; a local function, command, or HTTP client can also be a tool.

Harness

What it is
The runtime control environment that hosts models, tools, state, permissions, logs, budgets, human approval, and recovery.
What it is not
It can constrain risk and preserve evidence, but it cannot directly prevent model hallucinations.

MCP Servers, SaaS & Streaming Protocols

Implement tools, resources, state, Streamable HTTP, authentication, and client integration.

One thing to completeConnect different external capabilities for all three scenarios through MCP.
Before you begin, Forge already hasThe accepted solution from Module 12, Multi-Agent Collaboration & Orchestration, is this chapter’s starting point.
After this chapter, Forge canConnect Forge to external services through MCP, with a different tool in each scenario.
Smallest recovery pointValidate schemas and error contracts against a local unauthenticated MCP mock first.
Download this chapter labPython 3.12 · pytest · Pydantic · SQLite · deterministic Mock
Stage capability chain
01Define verifiable tool argument contracts that reject missing and unknown fields02Register MIME-typed resources and constrain reads to explicitly allowed URI prefixes03Declare template variables and validate missing or extra values before rendering04Assemble streamed responses in order and reject duplicates, gaps, and events after termination05Validate token expiry and scopes while constructing least-privilege authorization headers06Retry only safe requests with backoff and bind write requests to idempotency keys07Negotiate client/server capabilities and resume compatible sessions only from acknowledged cursors
Each step adds one verifiable capability; every later step begins from code and tests accepted in the previous step.

Concept calibration\n\nPut the concepts used in this module back inside their engineering boundaries before you work with the code.\n\n
\n\n### MCP mcp\n\n- What it is: A standard protocol for connecting tools, resources, and prompts over local or remote transports, with explicit schemas describing capabilities.\n- What it is not: It is not a remote API, an agent, or the only way to connect tools; identity, permission, and side effects still require governance after connection.\n- Relationship to adjacent concepts: Tools are executable capabilities, resources are readable information, and prompts are templates. MCP standardizes discovery and invocation, while the harness controls execution.\n- Where it lives in HeatStack Forge: Forge validates MCP capabilities, schemas, and identity scope, then registers permitted capabilities into the existing tool system rather than bypassing governance.\n- Typical misuse and correction: A common misuse exposes every server capability to the model. Use allowlists, least privilege, timeouts, audit, high-risk approval, and disconnect tests.\n\n### Tool tool\n\n- What it is: A constrained capability an agent invokes through explicit arguments, permissions, execution boundaries, and result contracts, such as a function, command, database query, or HTTP client.\n- What it is not: A tool is not MCP or any arbitrary script. A protocol can expose capability, while the harness still decides whether invocation is allowed and how side effects are handled.\n- Relationship to adjacent concepts: The agent loop selects tools, schemas constrain arguments, the harness enforces permission and isolation, and verification checks whether results advanced the goal.\n- Where it lives in HeatStack Forge: Forge stores tool contracts and risk levels, produces a change plan before execution, waits for approval on high-risk writes, and records results and recovery data.\n- Typical misuse and correction: A common misuse lets the model invent arguments from a tool name. Use strict schemas, deterministic validation, timeouts, idempotency keys, and refusal tests.\n\n### Harness harness\n\n- What it is: The runtime control environment that hosts models, tools, state, permissions, logs, budgets, human approval, isolation, and recovery.\n- What it is not: It can reduce risk, reject actions, and preserve evidence, but it cannot guarantee that model output is always correct or replace domain acceptance checks.\n- Relationship to adjacent concepts: The agent loop runs inside the harness; tool, memory, and MCP connections obey its policies; observability records execution and evaluation judges behavior.\n- Where it lives in HeatStack Forge: The Forge harness manages workspace, tools, approval queue, budget, checkpoints, audit logs, and rollback, separating model suggestions from real side effects.\n- Typical misuse and correction: A common misuse treats a system prompt as a security boundary. Enforce permissions, paths, network access, secrets, budgets, and rollback outside the model.\n\n

STAGE 01

Define verifiable tool argument contracts that reject missing and unknown fields

Start with a concrete problem

Why is checking only required fields not enough?

In the accepted solution from Module 12, Forge can orchestrate multiple agents to complete tasks, but it has not yet connected external tools through MCP.

This stage creates forge/mcp/schema.py, defines the ToolSchema dataclass, and implements the validate method to check arguments against the contract.

If only required fields are checked, an attacker or accidental call could pass undeclared arguments, such as shell, injecting extra capabilities.

The test test_unknown_arguments_are_rejected requires that passing a shell argument raises a ValueError containing unknown.

Make a prediction

When implementing ToolSchema.validate, what happens if you only check the required fields and ignore the allowed fields?

  • All valid arguments pass validation, and unknown arguments are also accepted
  • Unknown arguments are silently ignored
  • Validation raises a TypeError
  • Only missing required fields cause an error

Reasoning guide: The correct answer is the first option: checking only required fields lets unknown arguments through because the validation logic does not reject keys that are not in the allowed tuple.

What it is

Tool schema is a contract for argument shape

ToolSchema uses the required tuple to declare argument names that must be present, and the allowed tuple to declare all acceptable argument names.

The validate method first checks for missing required arguments, then checks for unknown arguments, and finally returns a copy of the arguments.

This contract only validates argument shape; it does not handle execution permissions or side effects.

What it is not

Tool schema is not a security boundary

Passing schema validation does not mean the tool call is safe, because execution environment, permissions, and side effects are still controlled by the Harness.

It cannot prevent the model from producing incorrect argument values; it only rejects inputs with invalid shape.

It is not the MCP protocol itself, but part of an MCP tool definition.

How it relates to neighboring concepts

Relationship between schema, Harness, and Agent Loop

After the Agent Loop selects a tool, the Harness uses ToolSchema.validate to check arguments before deciding whether to allow the call.

The schema’s allowed tuple limits the set of acceptable arguments, preventing unintended capability injection.

After validation passes, the Harness can still reject execution based on policy, for example if dangerous argument values are detected.

Boundary decision for this stage: Tool schema only validates argument shape, not execution permissions or side effects; therefore, even if validation passes, the Harness must independently check permissions and side effects.

STAGE 02

Register MIME-typed resources and constrain reads to explicitly allowed URI prefixes

Start with a concrete problem

How can we prevent resource registration from escaping allowed URI prefixes?

Currently forge/mcp/schema.py exists, but there is no resource registry. The test file tests/test_stage.py imports forge.mcp.resources, but that module has not been created, causing a collection error: ModuleNotFoundError: No module named 'forge.mcp.resources'.

You need to create forge/mcp/resources.py and implement the Resource dataclass and ResourceRegistry class. ResourceRegistry receives a tuple of allowed URI prefixes in its constructor and must enforce that registered resources have URIs starting with one of these prefixes.

If a registered URI is not within the allowed prefixes, a ValueError must be raised with the word “allowed” in the message. The registry must also support reading registered resources and preserve their MIME type.

Make a prediction

In ResourceRegistry.register, if the passed resource.uri does not start with any prefix in self.allowed_prefixes, what should happen?

  • Store the resource anyway, because the registry only stores and does not validate.
  • Raise ValueError indicating the URI is outside allowed prefixes.
  • Silently ignore the resource without storing or raising an error.
  • Automatically convert the URI to a path under an allowed prefix before storing.

Reasoning guide: The correct option is to raise ValueError. The registry must enforce the prefix policy; otherwise out-of-scope resources would be accepted, potentially exposing content that should not be accessible. Silently ignoring or auto-converting would mask errors and prevent callers from detecting configuration problems.

What it is

What ResourceRegistry is

ResourceRegistry is an in-memory container for resources that maintains a mapping from URI to Resource objects and enforces URI prefix policy at registration time.

It defines an acceptable URI namespace through the allowed_prefixes tuple, for example ("forge://workspace/",) means only URIs starting with forge://workspace/ are allowed.

The register method checks whether the resource URI starts with any allowed prefix; if not, it raises ValueError, preventing out-of-scope resources from entering the registry.

What it is not

What ResourceRegistry is not

It is not a content parser or transport layer: it does not care about the specific format of resource content and does not send resources to clients.

It is not a security boundary by itself: it only performs prefix checks, but callers must still ensure allowed_prefixes is configured correctly and that reads also validate the URI.

It is not a global resource manager: each registry instance maintains its own mapping independently, and data is not shared between instances.

How it relates to neighboring concepts

Relationships with other components

The Resource dataclass is an immutable object with three fields: uri, mime_type, and content; the registry stores references to these objects.

ResourceRegistry depends on allowed_prefixes to define its boundary, and both register and read methods use this boundary for validation.

The test test_registered_resource_preserves_media_type verifies that a registered resource’s MIME type is preserved after reading, while test_resource_outside_allowed_prefix_is_rejected verifies that out-of-scope resources are rejected.

Boundary decision for this stage: The registry only handles storage and prefix policy, not content parsing or transport. Therefore, register must check URI prefixes to prevent out-of-scope resources from being accepted; read must also check URI prefixes to prevent callers from directly reading unregistered out-of-scope URIs.

STAGE 03

Declare template variables and validate missing or extra values before rendering

Start with a concrete problem

Missing variables are silently replaced with empty strings, producing incomplete prompts

In the previous stage, we implemented ResourceRegistry, but there is no prompt template yet. Now we need to create forge/mcp/prompts.py and implement the PromptTemplate class.

The test test_missing_prompt_variable_is_rejected requires that when rendering a prompt template, if a declared variable is missing, a ValueError must be raised instead of silently filling it with an empty string.

Currently, forge/mcp/prompts.py does not exist in the starting code, so running the tests will raise ModuleNotFoundError. We need to implement PromptTemplate from scratch and ensure the variable validation logic is correct.

Make a prediction

What happens if the render method of PromptTemplate fills missing variables with empty strings?

  • The rendered result is incomplete, but no error is raised, and the test fails
  • The rendered result is complete, and the test passes
  • A ValueError is raised, and the test passes
  • The rendered result is incomplete, but the test passes

Reasoning guide: The correct answer is the first option. If missing variables are filled with empty strings, the rendered result will lack necessary information, and the test test_missing_prompt_variable_is_rejected will fail because it expects a ValueError to be raised.

What it is

PromptTemplate is a variable contract and renderer

PromptTemplate declares the set of variables required for rendering and validates that the provided values exactly match before rendering.

It explicitly lists all required variables through the variables tuple, and the render method checks for missing and extra variables to ensure the contract is honored.

Rendering uses Python’s str.format mechanism to replace placeholders in the template with the provided values.

What it is not

PromptTemplate does not handle prompt content quality or model invocation

PromptTemplate does not evaluate whether the prompt text is effective or high-quality; it only guarantees the completeness of variable substitution.

It does not involve model calls, API requests, or any external side effects; it is purely a data transformation component.

It does not handle type conversion or default values for variables; all variables must be explicitly provided.

How it relates to neighboring concepts

Relationship between PromptTemplate and ResourceRegistry

ResourceRegistry manages readable resources, while PromptTemplate manages renderable prompt templates; both are part of MCP capabilities.

PromptTemplate can reference resource content from ResourceRegistry as variable values, but variable validation is independent of resource existence.

Both follow explicit schema constraints, but PromptTemplate focuses on the variable contract, while ResourceRegistry focuses on resource discovery.

Boundary decision for this stage: PromptTemplate only handles the variable contract and rendering, not prompt content quality or model invocation; missing or extra variables must raise ValueError instead of being silently handled.

STAGE 04

Assemble streamed responses in order and reject duplicates, gaps, and events after termination

Start with a concrete problem

Stream events must arrive in order and stop after termination

In streaming protocols, events may arrive out of order due to network retransmission, concurrent pushes, or client errors, but the final concatenated result must match the original sending order.

If the assembler blindly accepts any sequence number, it will incorrectly concatenate data after a missing event, producing unverifiable output.

This stage requires implementing StreamAssembler, which only accepts contiguous increasing sequence numbers and rejects any events after receiving a done event.

In the current starting code, the file forge/mcp/streaming.py does not exist, so the test test_sequence_gap_is_rejected fails with an import error.

Make a prediction

If StreamAssembler.push receives sequence number 3 while the expected sequence number is 2, what should happen?

  • Accept the event and update the expected sequence to 4
  • Raise ValueError because the sequence is not contiguous
  • Ignore the event and wait for sequence 2
  • Automatically insert an empty event and then accept

Reasoning guide: The correct behavior is to raise ValueError, because streaming protocols require events to arrive strictly in order; any gap indicates data loss or reordering and must be explicitly rejected rather than silently repaired.

What it is

What StreamAssembler is

StreamAssembler is a stateful object that maintains next_sequence (the next expected sequence number), chunks (a list of received text chunks), and finished (whether a termination event has been received).

Its push method validates event sequence continuity, event type legality, and termination state before applying the event data.

The text method returns the concatenated result only when finished is true; otherwise it raises an exception to prevent returning incomplete content.

What it is not

What StreamAssembler is not

It does not handle network transport, retransmission, or buffering of out-of-order events; those belong to the transport layer or a higher-level protocol.

It does not parse event content or validate business semantics; it only focuses on sequence numbers and termination state.

It is not thread-safe; if multiple threads push events concurrently, external synchronization is required.

How it relates to neighboring concepts

Relationships with other components

StreamAssembler uses next_sequence as a cursor, comparing it with the sequence number in push to ensure that only the expected next event is accepted.

The finished state is set by the done event; once true, any subsequent push immediately raises an exception.

The chunks list stores text blocks in the order received, and the text method concatenates them into the final string after termination.

Boundary decision for this stage: StreamAssembler only handles ordering and termination state, not network transport or retransmission; any out-of-order or post-termination events must be rejected rather than repaired.

STAGE 05

Validate token expiry and scopes while constructing least-privilege authorization headers

Start with a concrete problem

A read-only token is misused for a write operation

In SaaS integrations, we often receive an access token (AccessToken) that carries an expiry time and a set of scopes.

The current code in AccessToken.headers_for only checks whether the token is expired, but does not check whether the requested operation is within the token’s allowed scopes.

As a result, a token with only issues:read permission can generate an Authorization header for an issues:write operation, leading to unauthorized write calls.

We need to validate both token freshness and required scope before generating the authorization header, otherwise raise PermissionError.

Make a prediction

In AccessToken.headers_for, if the token is not expired but lacks the required scope, what does the current code do?

  • Raises PermissionError because scope check already exists
  • Returns Authorization header because only expiry is checked
  • Returns empty dictionary because scope is missing
  • Raises ValueError because scope format is invalid

Reasoning guide: The current code only checks now >= self.expires_at and does not check whether required_scope is in self.scopes. Therefore, as long as the token is not expired, it returns the Authorization header even if the scope does not match.

What it is

AccessToken is a minimal authorization decision unit

AccessToken is an immutable data class that encapsulates the token value, scope set, and expiry time.

Its headers_for method performs two checks before generating the authorization header: first expiry, then scope.

This order ensures we do not waste scope checks on an already invalid token and avoids leaking the token value.

What it is not

AccessToken does not handle token acquisition or refresh

AccessToken only handles validation and header generation; it does not fetch new tokens from an OAuth server or refresh expired tokens.

It also does not perform actual HTTP calls or manage global permission policies; it only makes a local decision for a single request’s required scope.

How it relates to neighboring concepts

Order relationship between expiry and scope checks

Expiry check is a precondition: if the token is expired, it cannot be used regardless of scope, so we raise an expiry error first.

Scope check is authorization: only when the token is unexpired and contains the required scope do we generate the Authorization header.

sanitized_headers is independent of AccessToken; it is used to redact the authorization header in logs to prevent token value leakage.

Boundary decision for this stage: Before generating the Authorization header, both conditions must be satisfied: the token is unexpired and contains the required scope; if either fails, raise PermissionError instead of returning a partial or empty header.

STAGE 06

Retry only safe requests with backoff and bind write requests to idempotency keys

Start with a concrete problem

Why can’t we retry all requests?

When integrating with external SaaS services, network jitter or transient server errors are common, so we might want to automatically retry failed requests.

But not all requests are safe to retry: for example, a POST request that creates an order, if the first request succeeded but the response was lost, retrying would create two orders.

In this stage we will implement a plan_request function that decides retry policy based on HTTP method and whether an idempotency key is provided, to avoid duplicate side effects.

Currently there is no forge/mcp/saas.py file, so tests test_get_retries_are_bounded and test_post_retry_requires_idempotency_key fail with an import error.

Make a prediction

Before implementing plan_request, predict: for plan_request("POST", 1) (without providing an idempotency key), what should the function return?

  • Return a RequestPlan with attempts equal to 2
  • Raise ValueError indicating an idempotency key is required
  • Return a RequestPlan with attempts equal to 1
  • Return None

Reasoning guide: The correct answer is to raise ValueError. Because POST requests may have side effects, retrying without an idempotency key is unsafe, so the function must refuse to plan a retry.

What it is

Retry policy planner

plan_request is a pure function that computes a RequestPlan dataclass based on HTTP method, transient failure count, and an optional idempotency key.

The function only decides whether to retry and how many attempts to make; it does not perform actual network requests or execute retries.

For GET requests, because they are idempotent, it can safely increase attempts based on transient failures, but attempts are capped.

For POST requests, retries are allowed only if an idempotency key is provided; otherwise it raises ValueError.

What it is not

Not a network client or executor

plan_request does not send HTTP requests or handle responses; it only generates a plan object.

It does not generate idempotency keys or validate their format; it only checks for their presence.

It is not a generic retry decorator that automatically wraps arbitrary functions for retrying.

How it relates to neighboring concepts

Relationship with other components

The RequestPlan dataclass contains method, attempts, and headers fields, where headers includes Idempotency-Key if an idempotency key is provided.

Later SaaS clients can use RequestPlan to actually execute requests and retry according to attempts.

plan_request relies on the caller to pass correct arguments; it maintains no state itself.

Boundary decision for this stage: Boundary decision: plan_request only plans retry policy, not actual network calls or retry execution; for write requests, it must require the caller to provide an idempotency key, otherwise refuse to plan.

STAGE 07

Negotiate client/server capabilities and resume compatible sessions only from acknowledged cursors

Start with a concrete problem

Protocol version can be silently changed during session resume

The file forge/mcp/session.py does not exist yet, so tests test_negotiation_uses_intersection and test_resume_rejects_protocol_change fail at import time because the module forge.mcp.session is missing.

Even after adding the module, if the resume function unconditionally trusts the protocol version passed by the caller, a session originally negotiated as 2026-01 could be resumed as 2025-06, causing subsequent stream processing to use the wrong message format.

This stage requires implementing both negotiate and resume, ensuring that on resume the protocol version must match the established session, otherwise a ValueError is raised.

Make a prediction

Before implementing resume, which behavior do you think is correct?

  • During resume, the protocol version can be renegotiated as long as the client supports it.
  • Resume must use the original session’s protocol version; any mismatch should be rejected.
  • Resume only needs to check that the cursor is greater than the acknowledged sequence; version is irrelevant.
  • Resume should automatically select the latest version supported by both client and server.

Reasoning guide: The correct option is the second one: resume must use the original session’s protocol version. Because the session is already established, the protocol version is a contract between both parties; changing the version means the message format may be incompatible and must be rejected.

What it is

Essence of session negotiation and resume

Session negotiation (negotiate) determines a common protocol version and a set of mutually supported capabilities between client and server, producing an immutable Session object.

Session resume (resume) continues processing events after a disconnection using an acknowledged cursor, but must guarantee the protocol version is unchanged and the cursor does not regress.

What it is not

Session resume is not renegotiation

Resume is not renegotiation: it cannot change the protocol version or capability set, otherwise it would break the established communication contract.

Resume also does not handle actual stream transport or reconnection; it only checks state and returns a new session object.

How it relates to neighboring concepts

Relationships between components

negotiate produces a Session, and resume consumes a Session and returns an updated Session.

The protocol_version and capabilities of a Session must remain unchanged during resume; only acknowledged_sequence may be updated.

Boundary decision for this stage: On resume, if the protocol version is inconsistent or the cursor is less than the acknowledged sequence, a ValueError must be raised rather than silently accepting or attempting renegotiation.

Complete the chapter

Connect Forge to external services through MCP, with a different tool in each scenario.

FORGE / LOCAL CHECK

Local lab self-check

  1. Not started
  2. 2Reading
  3. 3Lab downloaded
  4. 4Test result read
  5. 5Local check passed

verification.json is parsed only in this browser and is never uploaded. This checks the lab contract; it is not server certification or third-party endorsement.

Concept calibration and one-week review

Three questions check the chapter's boundaries, delivery evidence, and recovery path. Answers stay in this browser.

  1. 1Which result best proves that you completed “MCP Servers, SaaS & Streaming Protocols”?
  2. 2Which statement most accurately describes “MCP”?
  3. 3When the lab is blocked, which action is the chapter's recommended minimum recovery point?