Skip to content

Commit 84d4fe8

Browse files
authored
refactor(cloudflare): improve McpAgent constraint abstraction (#517)
- Rename mcp-transport.ts to mcp-agent.ts for better semantic clarity - Remove custom wrapper functions from index.ts - Integrate URLPattern-based constraint extraction directly into SentryMCP class - Add helper functions for flexible routing: /mcp, /mcp/:org, /mcp/:org/:project - Maintain backward compatibility with existing constraint handling
1 parent ff5f9ba commit 84d4fe8

7 files changed

Lines changed: 589 additions & 346 deletions

File tree

‎packages/mcp-cloudflare/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"@types/react-scroll-to-bottom": "^4.2.5",
3838
"@vitejs/plugin-react": "catalog:",
3939
"tailwindcss": "catalog:",
40+
"urlpattern-polyfill": "^10.1.0",
4041
"vite": "catalog:",
4142
"vitest": "catalog:",
4243
"wrangler": "catalog:"

‎packages/mcp-cloudflare/src/server/index.ts‎

Lines changed: 6 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -1,116 +1,24 @@
11
import * as Sentry from "@sentry/cloudflare";
22
import OAuthProvider from "@cloudflare/workers-oauth-provider";
3-
import SentryMCP from "./lib/mcp-transport";
3+
import SentryMCP from "./lib/mcp-agent";
44
import app from "./app";
55
import { SCOPES } from "../constants";
66
import type { Env } from "./types";
77
import getSentryConfig from "./sentry.config";
8-
import { isValidSlug } from "./lib/slug-validation";
98

109
// required for Durable Objects
1110
export { SentryMCP };
1211

13-
// Custom wrapper to preserve path parameters via headers.
14-
//
15-
// ARCHITECTURAL LIMITATION:
16-
// We must pass constraints via headers because the agents library rewrites URLs
17-
// from /mcp/org/project to /streamable-http, losing path information.
18-
//
19-
// IDEAL SOLUTION (not possible with current libraries):
20-
// Each URL path (/mcp/org1/project1) would map to a separate DO instance,
21-
// providing immutable configuration per context.
22-
//
23-
// CURRENT SOLUTION:
24-
// 1. Extract org/project from URL path here
25-
// 2. Pass as headers (X-Sentry-Org-Slug, X-Sentry-Project-Slug)
26-
// 3. DO extracts headers and reconfigures when constraints change
27-
//
28-
// This wrapper ensures:
29-
// - Security: External clients cannot bypass URL-based constraints
30-
// - Validation: Slugs are validated before being passed to DO
31-
// - Compatibility: Works with both SSE and streamable-http transports
32-
const createMcpHandler = (basePath: string, isSSE = false) => {
33-
const handler = isSSE ? SentryMCP.serveSSE("/*") : SentryMCP.serve("/*");
34-
35-
return {
36-
fetch: (request: Request, env: unknown, ctx: ExecutionContext) => {
37-
const url = new URL(request.url);
38-
39-
// Always create new headers to prevent external manipulation
40-
const headers = new Headers(request.headers);
41-
42-
// Remove any externally-set constraint headers for security
43-
// This prevents clients from bypassing URL-based constraints
44-
headers.delete("X-Sentry-Org-Slug");
45-
headers.delete("X-Sentry-Project-Slug");
46-
47-
// Extract org/project from URL path
48-
// NOTE: Extra path segments after project (e.g., /mcp/org/project/extra) are
49-
// intentionally ignored - the MCP handler manages any additional routing
50-
// IMPORTANT: /sse/message and /mcp/message are reserved SSE protocol endpoints
51-
// and must not be interpreted as organization slugs
52-
const pathMatch = url.pathname.match(
53-
/^\/(mcp|sse)(?:\/([a-zA-Z0-9._-]{1,100}))?(?:\/([a-zA-Z0-9._-]{1,100}))?/,
54-
);
55-
56-
// Check if this is a reserved protocol endpoint
57-
const isReservedEndpoint =
58-
url.pathname === "/sse/message" ||
59-
url.pathname.startsWith("/sse/message?") ||
60-
url.pathname === "/mcp/message" ||
61-
url.pathname.startsWith("/mcp/message?");
62-
63-
// Validate and set headers based on URL path (unless it's a reserved endpoint)
64-
if (!isReservedEndpoint && pathMatch?.[2]) {
65-
// Organization slug is present - validate it
66-
if (!isValidSlug(pathMatch[2])) {
67-
return new Response(
68-
JSON.stringify({
69-
error: "invalid_request",
70-
error_description: "Invalid organization slug format",
71-
}),
72-
{
73-
status: 400,
74-
headers: { "Content-Type": "application/json" },
75-
},
76-
);
77-
}
78-
headers.set("X-Sentry-Org-Slug", pathMatch[2]);
79-
80-
// Project slug is optional but must be valid if present
81-
if (pathMatch[3]) {
82-
if (!isValidSlug(pathMatch[3])) {
83-
return new Response(
84-
JSON.stringify({
85-
error: "invalid_request",
86-
error_description: "Invalid project slug format",
87-
}),
88-
{
89-
status: 400,
90-
headers: { "Content-Type": "application/json" },
91-
},
92-
);
93-
}
94-
headers.set("X-Sentry-Project-Slug", pathMatch[3]);
95-
}
96-
}
97-
// If no path params, headers remain deleted (cleared above)
98-
99-
// Create a new request with the sanitized headers
100-
const modifiedRequest = new Request(request, { headers });
101-
return handler.fetch(modifiedRequest, env, ctx);
102-
},
103-
};
104-
};
12+
// SentryMCP handles URLPattern-based constraint extraction from request URLs
13+
// and passes context to Durable Objects via headers for org/project scoping.
10514

10615
const oAuthProvider = new OAuthProvider({
10716
apiHandlers: {
108-
// NOTE: OAuthProvider only does prefix matching, not parameterized routes.
109-
// So "/mcp" will match "/mcp", "/mcp/org", "/mcp/org/project" etc.
110-
// We use a custom wrapper to extract path params for /mcp endpoints only.
17+
// OAuthProvider uses prefix matching, so "/mcp" matches all MCP routes.
18+
// SentryMCP.serve() uses URLPattern to extract org/project constraints from URLs.
11119
// SSE endpoints don't support subpath constraints due to protocol limitations.
11220
"/sse": SentryMCP.serveSSE("/sse"),
113-
"/mcp": createMcpHandler("/mcp", false),
21+
"/mcp": SentryMCP.serve("/mcp/:org?/:project?"),
11422
},
11523
// @ts-ignore
11624
defaultHandler: app,
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import { describe, it, expect } from "vitest";
2+
import "urlpattern-polyfill";
3+
import {
4+
isReservedEndpoint,
5+
extractConstraintsWithURLPattern,
6+
} from "./constraint-utils";
7+
8+
describe("isReservedEndpoint", () => {
9+
it("should identify reserved SSE and MCP endpoints", () => {
10+
expect(isReservedEndpoint("/sse/message")).toBe(true);
11+
expect(isReservedEndpoint("/mcp/message")).toBe(true);
12+
});
13+
14+
it("should identify reserved endpoints with query parameters", () => {
15+
expect(isReservedEndpoint("/sse/message?param=value")).toBe(true);
16+
expect(isReservedEndpoint("/mcp/message?foo=bar&baz=qux")).toBe(true);
17+
});
18+
19+
it("should not identify non-reserved endpoints", () => {
20+
expect(isReservedEndpoint("/mcp")).toBe(false);
21+
expect(isReservedEndpoint("/mcp/org")).toBe(false);
22+
expect(isReservedEndpoint("/mcp/org/project")).toBe(false);
23+
expect(isReservedEndpoint("/api/something")).toBe(false);
24+
expect(isReservedEndpoint("/")).toBe(false);
25+
});
26+
});
27+
28+
describe("extractConstraintsWithURLPattern", () => {
29+
describe("reserved endpoints", () => {
30+
it("should return null constraints for reserved SSE endpoints", () => {
31+
const result = extractConstraintsWithURLPattern(
32+
"https://example.com/sse/message",
33+
"/mcp/:org?/:project?",
34+
);
35+
expect(result).toEqual({
36+
organizationSlug: null,
37+
projectSlug: null,
38+
});
39+
});
40+
41+
it("should return null constraints for reserved MCP endpoints", () => {
42+
const result = extractConstraintsWithURLPattern(
43+
"https://example.com/mcp/message",
44+
"/mcp/:org?/:project?",
45+
);
46+
expect(result).toEqual({
47+
organizationSlug: null,
48+
projectSlug: null,
49+
});
50+
});
51+
});
52+
53+
describe("pattern matching", () => {
54+
it("should extract no constraints for base /mcp path", () => {
55+
const result = extractConstraintsWithURLPattern(
56+
"https://example.com/mcp",
57+
"/mcp/:org?/:project?",
58+
);
59+
expect(result).toEqual({
60+
organizationSlug: null,
61+
projectSlug: null,
62+
});
63+
});
64+
65+
it("should extract organization slug for /mcp/:org pattern", () => {
66+
const result = extractConstraintsWithURLPattern(
67+
"https://example.com/mcp/sentry",
68+
"/mcp/:org?/:project?",
69+
);
70+
expect(result).toEqual({
71+
organizationSlug: "sentry",
72+
projectSlug: null,
73+
});
74+
});
75+
76+
it("should extract both organization and project slugs for /mcp/:org/:project pattern", () => {
77+
const result = extractConstraintsWithURLPattern(
78+
"https://example.com/mcp/sentry/my-app",
79+
"/mcp/:org?/:project?",
80+
);
81+
expect(result).toEqual({
82+
organizationSlug: "sentry",
83+
projectSlug: "my-app",
84+
});
85+
});
86+
87+
it("should handle URLs that don't match the pattern", () => {
88+
const result = extractConstraintsWithURLPattern(
89+
"https://example.com/api/something",
90+
"/mcp/:org?/:project?",
91+
);
92+
expect(result).toEqual({
93+
organizationSlug: null,
94+
projectSlug: null,
95+
});
96+
});
97+
});
98+
99+
describe("slug validation", () => {
100+
it("should return error for invalid organization slug", () => {
101+
const result = extractConstraintsWithURLPattern(
102+
"https://example.com/mcp/invalid..slug",
103+
"/mcp/:org?/:project?",
104+
);
105+
expect(result).toEqual({
106+
organizationSlug: null,
107+
projectSlug: null,
108+
error: "Invalid organization slug format",
109+
});
110+
});
111+
112+
it("should return error for invalid project slug", () => {
113+
const result = extractConstraintsWithURLPattern(
114+
"https://example.com/mcp/valid-org/invalid..project",
115+
"/mcp/:org?/:project?",
116+
);
117+
expect(result).toEqual({
118+
organizationSlug: null,
119+
projectSlug: null,
120+
error: "Invalid project slug format",
121+
});
122+
});
123+
124+
it("should accept valid slugs with allowed characters", () => {
125+
const result = extractConstraintsWithURLPattern(
126+
"https://example.com/mcp/my-org_v2.1/test-project_123",
127+
"/mcp/:org?/:project?",
128+
);
129+
expect(result).toEqual({
130+
organizationSlug: "my-org_v2.1",
131+
projectSlug: "test-project_123",
132+
});
133+
});
134+
});
135+
136+
describe("error handling", () => {
137+
it("should handle invalid URLPattern gracefully", () => {
138+
const result = extractConstraintsWithURLPattern(
139+
"https://example.com/mcp/org",
140+
"*{invalid",
141+
);
142+
expect(result).toEqual({
143+
organizationSlug: null,
144+
projectSlug: null,
145+
error: "Invalid URL pattern",
146+
});
147+
});
148+
149+
it("should handle malformed URLs gracefully", () => {
150+
const result = extractConstraintsWithURLPattern(
151+
"not-a-valid-url",
152+
"/mcp/:org?/:project?",
153+
);
154+
expect(result).toEqual({
155+
organizationSlug: null,
156+
projectSlug: null,
157+
error: "Invalid URL pattern",
158+
});
159+
});
160+
});
161+
162+
describe("query parameters and fragments", () => {
163+
it("should extract constraints from URLs with query parameters", () => {
164+
const result = extractConstraintsWithURLPattern(
165+
"https://example.com/mcp/sentry/my-project?param=value&foo=bar",
166+
"/mcp/:org?/:project?",
167+
);
168+
expect(result).toEqual({
169+
organizationSlug: "sentry",
170+
projectSlug: "my-project",
171+
});
172+
});
173+
174+
it("should extract constraints from URLs with fragments", () => {
175+
const result = extractConstraintsWithURLPattern(
176+
"https://example.com/mcp/sentry#section",
177+
"/mcp/:org?/:project?",
178+
);
179+
expect(result).toEqual({
180+
organizationSlug: "sentry",
181+
projectSlug: null,
182+
});
183+
});
184+
});
185+
});
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import type { UrlConstraints } from "@sentry/mcp-server/types";
2+
import { isValidSlug } from "./slug-validation";
3+
4+
/**
5+
* Check if a pathname is a reserved MCP protocol endpoint
6+
*/
7+
export function isReservedEndpoint(pathname: string): boolean {
8+
const reservedEndpoints = ["/sse/message", "/mcp/message"];
9+
10+
return reservedEndpoints.some(
11+
(endpoint) => pathname === endpoint || pathname.startsWith(`${endpoint}?`),
12+
);
13+
}
14+
15+
/**
16+
* Extract constraints using URLPattern for flexible routing
17+
* Supports: /mcp, /mcp/:org, /mcp/:org/:project
18+
*/
19+
export function extractConstraintsWithURLPattern(
20+
url: string,
21+
patternString: string,
22+
): UrlConstraints & { error?: string } {
23+
try {
24+
// Handle reserved SSE protocol endpoints first
25+
const urlObj = new URL(url);
26+
if (isReservedEndpoint(urlObj.pathname)) {
27+
return { organizationSlug: null, projectSlug: null };
28+
}
29+
30+
// Create URLPattern for flexible matching
31+
// Support multiple patterns: /mcp, /mcp/:org, /mcp/:org/:project
32+
const pattern = new URLPattern({ pathname: patternString });
33+
34+
// Try to match and extract parameters
35+
const result = pattern.exec(url);
36+
37+
if (!result) {
38+
// URL doesn't match pattern - could be valid for some endpoints
39+
return { organizationSlug: null, projectSlug: null };
40+
}
41+
42+
// Extract named parameters from URLPattern groups
43+
const { groups } = result.pathname;
44+
const org = groups?.org || null;
45+
const project = groups?.project || null;
46+
47+
// Validate slugs if present
48+
if (org && !isValidSlug(org)) {
49+
return {
50+
organizationSlug: null,
51+
projectSlug: null,
52+
error: "Invalid organization slug format",
53+
};
54+
}
55+
56+
if (project && !isValidSlug(project)) {
57+
return {
58+
organizationSlug: null,
59+
projectSlug: null,
60+
error: "Invalid project slug format",
61+
};
62+
}
63+
64+
return {
65+
organizationSlug: org,
66+
projectSlug: project,
67+
};
68+
} catch (error) {
69+
console.error("[MCP Agent] URLPattern error:", error);
70+
return {
71+
organizationSlug: null,
72+
projectSlug: null,
73+
error: "Invalid URL pattern",
74+
};
75+
}
76+
}

0 commit comments

Comments
 (0)