Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,268 changes: 1,268 additions & 0 deletions enterprise/e2e/auth/hurl/mcp-resources.all.hurl

Large diffs are not rendered by default.

38 changes: 0 additions & 38 deletions enterprise/index/enterprise_index.cc
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
#include <sourcemeta/core/yaml.h>

#include <sourcemeta/one/actions.h>
#include <sourcemeta/one/search.h>

#include <cassert> // assert
#include <cstddef> // std::size_t
Expand Down Expand Up @@ -60,43 +59,6 @@ auto load_custom_lint_rules(
}
}

auto generate_mcp_resources(const std::filesystem::path &search_metapack_path,
const sourcemeta::one::Configuration &configuration,
const std::size_t page_size,
sourcemeta::core::JSON &resources) -> void {
sourcemeta::one::SearchView search_view{search_metapack_path};
const auto total{search_view.count()};
const auto page_count{total == 0 ? std::size_t{1}
: (total + page_size - 1) / page_size};

for (std::size_t page_index{0}; page_index < page_count; ++page_index) {
const auto offset{page_index * page_size};
auto page{sourcemeta::core::JSON::make_object()};
auto entries{sourcemeta::core::JSON::make_array()};

search_view.for_each(
offset, page_size,
[&entries, &configuration](
const sourcemeta::one::SearchListEntry &entry) -> void {
std::string uri{configuration.origin};
uri.append(entry.path);

entries.push_back(sourcemeta::core::mcp_make_resource(
uri, entry.title.empty() ? entry.path : entry.title,
"application/schema+json", entry.description,
static_cast<std::size_t>(entry.bytes_raw),
static_cast<double>(entry.priority) / 100.0));
});

page.assign("resources", std::move(entries));
if (page_index + 1 < page_count) {
page.assign("nextCursor",
sourcemeta::core::JSON{std::to_string(offset + page_size)});
}
resources.assign(std::to_string(offset), std::move(page));
}
}

auto generate_mcp_tools(const sourcemeta::core::URITemplateRouterView &router,
sourcemeta::core::JSON &tools,
sourcemeta::core::JSON &tool_routes) -> void {
Expand Down
7 changes: 0 additions & 7 deletions enterprise/index/include/sourcemeta/one/enterprise_index.h
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@
#include <sourcemeta/core/json.h>
#include <sourcemeta/core/uritemplate.h>

#include <cstddef> // std::size_t
#include <filesystem> // std::filesystem::path
#include <string_view> // std::string_view
#include <unordered_set> // std::unordered_set

Expand All @@ -25,11 +23,6 @@ auto load_custom_lint_rules(
const sourcemeta::one::Resolver &resolver,
const sourcemeta::one::BuildDynamicCallback &callback) -> void;

auto generate_mcp_resources(const std::filesystem::path &search_metapack_path,
const sourcemeta::one::Configuration &configuration,
const std::size_t page_size,
sourcemeta::core::JSON &resources) -> void;

auto generate_mcp_tools(const sourcemeta::core::URITemplateRouterView &router,
sourcemeta::core::JSON &tools,
sourcemeta::core::JSON &tool_routes) -> void;
Expand Down
69 changes: 60 additions & 9 deletions enterprise/server/action_mcp_v1.cc
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@

#include <sourcemeta/one/http.h>
#include <sourcemeta/one/metapack.h>
#include <sourcemeta/one/search.h>

#include <cassert> // assert
#include <cstddef> // std::size_t, std::ptrdiff_t
#include <cstdint> // std::uint64_t
#include <exception> // std::exception
#include <filesystem> // std::filesystem
#include <iterator> // std::ranges::distance
Expand All @@ -25,42 +27,91 @@
namespace {

constexpr std::string_view MCP_TEMPLATE_MIME_TYPE{"application/schema+json"};
constexpr std::size_t MCP_RESOURCES_PAGE_SIZE{50};

const auto MCP_HASH_RESOURCES{
sourcemeta::core::JSON::Object::hash("resources")};
const auto MCP_HASH_NEXT_CURSOR{
sourcemeta::core::JSON::Object::hash("nextCursor")};

} // namespace

namespace sourcemeta::one::enterprise {

auto ActionMCP_v1::on_resources_list(const sourcemeta::core::JSON &request_json)
const -> sourcemeta::core::JSON {
auto ActionMCP_v1::on_resources_list(const sourcemeta::core::JSON &request_json,
const std::string_view credential)
-> sourcemeta::core::JSON {
const auto &id{request_json.at("id")};

std::string cursor_key{"0"};
std::uint64_t offset{0};
const auto *params{sourcemeta::core::jsonrpc_params(request_json)};
if (params != nullptr && params->defines("cursor")) {
const auto cursor_input{params->at("cursor").to_string()};
if (!cursor_input.empty()) {
const auto parsed{sourcemeta::core::to_uint64_t(cursor_input)};
if (!parsed.has_value()) {
// A cursor must parse and align to a page boundary. Reject before the
// catalog scan so a malformed cursor cannot force the O(N) walk
if (!parsed.has_value() ||
parsed.value() % MCP_RESOURCES_PAGE_SIZE != 0) {
return sourcemeta::core::jsonrpc_make_error(
&id, -32602, "Invalid resource list cursor",
sourcemeta::core::JSON{
"Use the `nextCursor` returned by a prior resources/list "
"response, or omit it to start from the beginning"});
}
cursor_key = std::to_string(parsed.value());
offset = parsed.value();
}
}

const auto &resources{this->mcp_metadata_.at("resources")};
if (!resources.defines(cursor_key)) {
// The pages are not pre-baked: a caller only sees the schemas it is admitted
// to, so the list is filtered against its credential at request time, exactly
// as the search surface does
const auto &authentication{this->dispatcher().authentication()};
auto resources{sourcemeta::core::JSON::make_array()};
std::uint64_t admitted{0};
this->search_view_.for_each(

@augmentcode augmentcode Bot Jun 19, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

enterprise/server/action_mcp_v1.cc:68: cursor values that are not a multiple of MCP_RESOURCES_PAGE_SIZE are only rejected after walking the full search_view_, so malformed cursors can still trigger a full scan. Consider validating the modulo constraint before the for_each to fail fast.

Severity: low

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

0, this->search_view_.count(),
[this, credential, &authentication, &resources, &admitted,
offset](const sourcemeta::one::SearchListEntry &entry) -> void {
if (!authentication.admits(entry.path, credential).allowed) {
return;
}

const auto position{admitted++};
if (position < offset || position >= offset + MCP_RESOURCES_PAGE_SIZE) {
return;
}

std::string uri{this->allowed_origin_};
uri.append(entry.path);
resources.push_back(sourcemeta::core::mcp_make_resource(
uri, entry.title.empty() ? entry.path : entry.title,
MCP_TEMPLATE_MIME_TYPE, entry.description,
static_cast<std::size_t>(entry.bytes_raw),
static_cast<double>(entry.priority) / 100.0));
});

// The alignment of a non-zero cursor is already validated above. A cursor
// past the end of the filtered catalog is out of range, though zero is always
// valid even when the catalog is empty
if (offset != 0 && offset >= admitted) {
return sourcemeta::core::jsonrpc_make_error(
&id, -32602, "Invalid resource list cursor",
sourcemeta::core::JSON{
"Use the `nextCursor` returned by a prior resources/list "
"response, or omit it to start from the beginning"});
}

return sourcemeta::core::jsonrpc_make_success(id, resources.at(cursor_key));
auto page{sourcemeta::core::JSON::make_object()};
page.assign_assume_new("resources", std::move(resources), MCP_HASH_RESOURCES);
if (offset + MCP_RESOURCES_PAGE_SIZE < admitted) {
page.assign_assume_new("nextCursor",
sourcemeta::core::JSON{std::to_string(
offset + MCP_RESOURCES_PAGE_SIZE)},
MCP_HASH_NEXT_CURSOR);
}

return sourcemeta::core::jsonrpc_make_success(id, std::move(page));
}

auto ActionMCP_v1::on_initialize(const sourcemeta::core::JSON &request_json)
Expand Down Expand Up @@ -260,7 +311,7 @@ auto ActionMCP_v1::process_one(
return this->on_tools_list(version, request_json);
}
if (method == sourcemeta::core::MCP_METHOD_RESOURCES_LIST) {
return this->on_resources_list(request_json);
return this->on_resources_list(request_json, credential);
}
if (method == sourcemeta::core::MCP_METHOD_RESOURCES_READ) {
return this->on_resources_read(request_json, credential);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
#include <sourcemeta/one/http.h>
#include <sourcemeta/one/metapack.h>
#include <sourcemeta/one/router.h>
#include <sourcemeta/one/search.h>

#include <cassert> // assert
#include <exception> // std::exception, std::exception_ptr, std::rethrow_exception
Expand Down Expand Up @@ -37,7 +38,8 @@ class ActionMCP_v1 : public sourcemeta::one::RouterAction {
const sourcemeta::core::URITemplateRouter::Identifier identifier,
sourcemeta::one::Router &dispatcher)
: sourcemeta::one::RouterAction{base, router.base_path(),
router.base_url(), dispatcher} {
router.base_url(), dispatcher},
search_view_{base / "explorer" / "%" / "search.metapack"} {
router.arguments(identifier, [this](const auto &key, const auto &value) {
if (key == "responseSchema") {
this->response_schema_ = std::get<std::string_view>(value);
Expand Down Expand Up @@ -287,8 +289,8 @@ class ActionMCP_v1 : public sourcemeta::one::RouterAction {
sourcemeta::one::Encoding::Identity);
}

auto on_resources_list(const sourcemeta::core::JSON &request_json) const
-> sourcemeta::core::JSON;
auto on_resources_list(const sourcemeta::core::JSON &request_json,
std::string_view credential) -> sourcemeta::core::JSON;

auto on_initialize(const sourcemeta::core::JSON &request_json) const
-> sourcemeta::core::JSON;
Expand Down Expand Up @@ -319,6 +321,7 @@ class ActionMCP_v1 : public sourcemeta::one::RouterAction {
std::string_view response_schema_;
std::string_view request_schema_;
sourcemeta::core::JSON mcp_metadata_{nullptr};
sourcemeta::one::SearchView search_view_;
};

} // namespace sourcemeta::one::enterprise
Expand Down
7 changes: 0 additions & 7 deletions src/index/explorer.h
Original file line number Diff line number Diff line change
Expand Up @@ -643,13 +643,10 @@ struct GENERATE_MCP {
"schemas rather than guessing paths",
"application/schema+json"));

auto resources{sourcemeta::core::JSON::make_object()};
auto tools{sourcemeta::core::JSON::make_array()};
auto tool_routes{sourcemeta::core::JSON::make_object()};

#if defined(SOURCEMETA_ONE_ENTERPRISE)
sourcemeta::one::generate_mcp_resources(
action.dependencies.front(), configuration, MCP_PAGE_SIZE, resources);
{
const sourcemeta::core::URITemplateRouterView router_view{
action.dependencies.back()};
Expand Down Expand Up @@ -688,7 +685,6 @@ struct GENERATE_MCP {
document.assign(
std::string{sourcemeta::core::MCP_METHOD_RESOURCES_TEMPLATES_LIST},
std::move(resource_templates_response));
document.assign("resources", std::move(resources));
document.assign(std::string{sourcemeta::core::MCP_METHOD_TOOLS_LIST},
std::move(tools));
document.assign("toolRoutes", std::move(tool_routes));
Expand All @@ -700,9 +696,6 @@ struct GENERATE_MCP {
std::chrono::duration_cast<std::chrono::milliseconds>(timestamp_end -
timestamp_start));
}

private:
static constexpr std::size_t MCP_PAGE_SIZE{50};
};

// The mutable build state is only safe to read while holding its lock, as
Expand Down
Loading