Skip to content

How to Use MetaProvider in Spring AI for MCP App Metadata

I was building an MCP App in Spring AI, and everything seemed fine until I tried to load an external script from unpkg.com. The console screamed:

Refused to load the script 'https://unpkg.com/[email protected]'
because it violates the following Content Security Policy directive:
"default-src 'self'"

My MCP App wouldn’t work. The UI was broken. What was going on?

The Sandbox Problem

MCP Apps in Spring AI run in a sandboxed environment by default. This is a security feature, not a bug. But it means:

  1. External resources are blocked — CDN scripts, stylesheets, and fonts won’t load
  2. Tools don’t know their UI resources — When a tool is invoked, the client needs metadata to know which HTML resource to display

I needed a way to:

  • Tell the browser it’s okay to load scripts from unpkg.com
  • Link my @McpTool to the HTML resource defined via @McpResource

Discovering MetaProvider

I dug through the Spring AI MCP documentation and found MetaProvider.

MetaProvider.java
public interface MetaProvider {
Map<String, Object> getMeta();
}

It’s a simple functional interface that returns metadata as a Map<String, Object>. The metadata keys Spring AI recognizes:

Metadata KeyPurposeUsed On
ui.resourceUriLinks a tool to its HTML resource@McpTool
ui.csp.resourceDomainsWhitelists external domains for CSP@McpResource

First Attempt: Setting CSP on @McpResource

I created a CspMetaProvider to allow scripts from unpkg.com:

CspMetaProvider.java
public static final class CspMetaProvider implements MetaProvider {
@Override
public Map<String, Object> getMeta() {
return Map.of("ui",
Map.of("csp",
Map.of("resourceDomains",
List.of("https://unpkg.com"))));
}
}

Then I referenced it in my @McpResource annotation:

MyAppResource.java
@McpResource(
name = "My App Resource",
uri = "ui://myapp/app.html",
mimeType = "text/html;profile=mcp-app",
metaProvider = CspMetaProvider.class // Reference the CLASS, not instance
)
public String getAppResource() throws IOException {
ClassPathResource resource = new ClassPathResource("templates/myapp/app.html");
return resource.getContentAsString(Charset.defaultCharset());
}

Key insight: Use metaProvider = CspMetaProvider.class — you reference the class, not an instance. Spring AI instantiates it.

The Metadata Structure

The tricky part was the nested map structure. Let me break it down:

+-- "ui" (root key)
| +-- "csp" (nested under ui)
| | +-- "resourceDomains" (list of domain strings)
| | | +-- "https://unpkg.com"
| | | +-- "https://cdn.jsdelivr.net"

For ui.resourceUri, the structure is:

+-- "ui" (root key)
| +-- "resourceUri" (string value)
| | +-- "ui://myapp/app.html"

Second Attempt: Linking Tool to Resource

Now I needed to link my @McpTool to the HTML resource. I created another MetaProvider:

DiceMetaProvider.java
public static final class DiceMetaProvider implements MetaProvider {
@Override
public Map<String, Object> getMeta() {
return Map.of("ui",
Map.of("resourceUri", "ui://myapp/dice.html"));
}
}

And used it on my tool:

DiceTool.java
@McpTool(
title = "Roll Dice",
name = "roll-dice",
description = "Rolls an interactive 3D dice",
metaProvider = DiceMetaProvider.class
)
public String rollDice() {
return "Dice rolled!";
}

Now when the tool is invoked, the client knows to display ui://myapp/dice.html.

Common Mistakes I Made

1. Setting CSP on @McpTool instead of @McpResource

WRONG - CSP on @McpTool
@McpTool(
name = "my-tool",
metaProvider = CspMetaProvider.class // Wrong! CSP belongs on @McpResource
)

CSP metadata should be on the @McpResource that serves the HTML, not on the tool.

2. Using Wrong Metadata Structure

WRONG - Flat map
return Map.of("ui.resourceUri", "ui://myapp/app.html"); // Wrong!

The correct structure is nested:

CORRECT - Nested map
return Map.of("ui", Map.of("resourceUri", "ui://myapp/app.html"));

3. Forgetting Protocol in Domain URLs

WRONG - Missing protocol
List.of("unpkg.com") // Wrong! Browser won't allow this

Always include the protocol:

CORRECT - With protocol
List.of("https://unpkg.com")

4. Not Using Static Inner Class

WRONG - Non-static inner class
public class CspMetaProvider implements MetaProvider { // Wrong if not static
@Override
public Map<String, Object> getMeta() {
// ...
}
}

If you define it as an inner class, make it static final:

CORRECT - Static inner class
public static final class CspMetaProvider implements MetaProvider {
// ...
}

Complete Example: Dice App with MetaProvider

Here’s a complete working example:

DiceMcpApp.java
package com.example.mcp;
import org.springframework.ai.mcp.server.McpTool;
import org.springframework.ai.mcp.server.McpResource;
import org.springframework.ai.mcp.server.MetaProvider;
import org.springframework.core.io.ClassPathResource;
import java.io.IOException;
import java.nio.charset.Charset;
import java.util.List;
import java.util.Map;
public class DiceMcpApp {
@McpResource(
name = "Dice App Resource",
uri = "ui://dice/app.html",
mimeType = "text/html;profile=mcp-app",
metaProvider = DiceCspMetaProvider.class
)
public String getDiceResource() throws IOException {
ClassPathResource resource = new ClassPathResource("templates/dice/app.html");
return resource.getContentAsString(Charset.defaultCharset());
}
@McpTool(
title = "Roll Dice",
name = "roll-dice",
description = "Rolls an interactive 3D dice",
metaProvider = DiceResourceUriMetaProvider.class
)
public String rollDice() {
return "Dice rolled! Click to see the result.";
}
// CSP for the HTML resource
public static final class DiceCspMetaProvider implements MetaProvider {
@Override
public Map<String, Object> getMeta() {
return Map.of("ui",
Map.of("csp",
Map.of("resourceDomains",
List.of(
"https://unpkg.com",
"https://cdn.jsdelivr.net"
))));
}
}
// Link tool to resource
public static final class DiceResourceUriMetaProvider implements MetaProvider {
@Override
public Map<String, Object> getMeta() {
return Map.of("ui",
Map.of("resourceUri", "ui://dice/app.html"));
}
}
}

How It Works Together

+-------------------+ +---------------------+ +------------------+
| @McpResource | | MetaProvider | | Browser/Client |
| uri: ui://dice |---->| ui.csp. |---->| Allows scripts |
| metaProvider: | | resourceDomains: | | from unpkg.com |
| DiceCspMeta | | [unpkg.com] | | |
+-------------------+ +---------------------+ +------------------+
+-------------------+ +---------------------+ +------------------+
| @McpTool | | MetaProvider | | Client displays |
| name: roll-dice |---->| ui.resourceUri: |---->| dice.html when |
| metaProvider: | | ui://dice/app.html | | tool invoked |
| DiceResourceUri | | | | |
+-------------------+ +---------------------+ +------------------+

Why This Design?

Security by default: MCP Apps start in a locked-down sandbox. You must explicitly opt-in to external resources.

Explicit over implicit: The MetaProvider pattern makes you think about which domains you’re whitelisting.

Separation of concerns: CSP goes on the resource, resource URI goes on the tool. Each piece has its job.

If you’re building MCP Apps, you might also need:

  • Tool annotations — Use @McpTool attributes for title, name, description
  • Resource annotations — Use @McpResource for serving HTML templates
  • MCP Server configuration — Configure the MCP server endpoint in your Spring Boot app

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!


References

  1. Spring AI MCP Documentation: https://docs.spring.io/spring-ai/reference/api/mcp.html
  2. Model Context Protocol (MCP) Specification: https://modelcontextprotocol.io/
  3. Content Security Policy (CSP) on MDN: https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP

Comments