Asset Library MCP Server
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@Asset Library MCP Serversearch for datasets about sea surface temperature"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
Asset Library MCP Server
An MCP server exposing a searchable catalogue of research datasets over Streamable HTTP, hosted on AWS Lambda, using Bedrock for embeddings and LLM re-ranking. The corpus is 5,000 synthetic records. The point of the project is not retrieval at scale: it is that the tool layer has an authorization model, idempotent mutations, an append-only audit trail, enforced spend limits, and tests that prove all of them.
A deviation from SPEC.md §2, recorded up front: the spec's example record is a
media asset with media_type and duration_seconds. This catalogue uses
domain, format, record_count, license and institution instead. What
the project is actually about -- scoping, filtering, ranking, untrusted text --
needs a categorical filter and a numeric field, not those specific ones. The
tool contracts, id formats and every other rule are unchanged.
Everything below that describes behaviour is covered by a test. Where something is unproven or a known gap, it says so.
// tools/call -> search_assets
{ "query": "sea surface temperature", "limit": 3 }{
"query_id": "qry_8f1c2a4b9d7e",
"rerank_status": "ok",
"results": [
{
"asset_id": "ast_8qmnly",
"title": "<untrusted_asset_content asset_id=\"ast_8qmnly\">\nSea surface temperature for the Pearl River delta, 2015-2025, gridded\n</untrusted_asset_content>",
"domain": "climate",
"score": 0.726411,
"rerank_reason": "satellite radiometer SST, gridded, matches directly",
"content_flags": []
}
]
}The wrapper around title is not decoration. Asset text is attacker-controlled
and is labelled as data everywhere it crosses a boundary. See
Untrusted content.
Quickstart
Local, no AWS account, no credentials, no cost:
make install # uv venv + deps
make ddb-local # DynamoDB Local in docker on :8001
make seed # generate 5,000 records, embed, write data/index.npz
make test # 286 tests
make eval # retrieval regression suiteTo drive it as a real MCP client:
ASSET_MCP_DEV_TOKEN=dev-token make dev # serves on :8000
npx @modelcontextprotocol/inspector # connect to http://localhost:8000/mcpSet the Inspector's Authorization header to Bearer dev-token.
Related MCP server: DataCite MCP Server
Architecture
MCP client (Claude Code / Desktop / Inspector)
│ Streamable HTTP, stateless, JSON responses
│ Authorization: Bearer <token>
▼
Lambda Function URL (AuthType NONE — see Security model)
│
Mangum (ASGI → Lambda)
│
Starlette
├── AuthMiddleware ← 401s here, before MCP parses anything
├── /healthz ← unauthenticated liveness probe
└── /mcp MCPServer (mcp 2.0.0)
│
┌───────┼────────────────────┬──────────────────┐
Bedrock DynamoDB ×4 S3
titan-embed-text-v2 assets, proposals, index.npz (19 MB)
claude-haiku-4-5 idempotency, audit + index_meta.jsonFour tools:
Tool | Scope | Mutates |
|
| no |
|
| no |
|
| no — returns a reviewable proposal |
|
| yes — the only writer in the server |
Design decisions and trade-offs
Why propose/commit instead of direct mutation
An MCP tool is called by a model, and a model can be talked into calling it by text it read a moment earlier. Splitting the mutation into a call that computes a diff and a call that applies one makes the dangerous step require an identifier the model can only have obtained from the safe step, and gives a human or supervising agent something concrete to approve: these four tags, on this asset, expiring in fifteen minutes.
propose_tag's MCP description says in plain language that it has no side
effects and that commit_proposal is required. Clients surface descriptions to
users, so that sentence is part of the safety mechanism, not documentation about
it. A contract test asserts the text is actually there, because a description
that silently drifts is worse than none.
What it costs. Two round trips instead of one, a proposals table, a TTL to
reason about, and four new failure modes (EXPIRED, STALE_PROPOSAL,
PROPOSAL_NOT_PENDING, IN_PROGRESS) that a single update_tags would not
have. For a four-tool demo that is a poor trade on its own terms; it pays off
the moment a mutation is expensive or irreversible.
Why client-supplied idempotency keys instead of dedup-on-write
Dedup-on-write — hashing the request and rejecting a recent duplicate — cannot tell "the client retried after a timeout" from "the user genuinely wants this twice". Only the caller knows which it is, so only the caller can say.
The key is claimed before the work runs, not after:
PutItemwithattribute_not_exists(pk)claimsprincipal#key.On success, the work runs and the result is stored against the key before being returned.
A replay finds a
COMPLETEDrecord and returns the stored result verbatim with"replayed": true. Nothing is re-applied.A concurrent second commit finds an
IN_PROGRESSclaim and is rejected, rather than both proceeding.If the work fails, the claim is released, so a transient error does not poison the key for its 24-hour TTL.
Step 5 is easy to omit and produces a genuinely nasty bug: a client retries after a network blip and is told for a day that its commit already happened, when it never did.
What it costs. Clients must generate a key, and a client that reuses one across genuinely different intentions gets a silently wrong answer — the stored result. The key is scoped per principal so one caller cannot squat another's.
Why LLM re-ranking on top of vector search
Cosine similarity ranks on proximity, which is the wrong objective for a query
carrying an explicit constraint. "Temperature data, but gridded rather than
individual stations" is maximally similar to the station-level records, which
are exactly what the user just excluded. Two such cases are in the golden set
for that reason, flagged requires_rerank.
Note what re-ranking cannot do. If the correct record never enters the candidate
set, re-ranking cannot rescue it -- it only reorders what retrieval found. That
is a separate failure class, and the golden set separates it: seven cases are
flagged requires_semantic_embedding because the query paraphrases rather than
quotes ("rainfall measured by weather stations in Spain" against "Daily
precipitation totals ... rain gauge network ... the Iberian Peninsula", which
share no tokens at all). Those measure the embedding model; the requires_rerank
cases measure the re-ranker. Conflating them would attribute a retrieval failure
to the wrong component.
What it costs. A Bedrock call on the read path: roughly 2,000 input and 300
output tokens, about $0.0035 per search, and several hundred milliseconds
against a sub-millisecond vector search. That is a large multiple, and it is why
the re-ranker is bounded (limit × 3 candidates, summaries truncated to 400
chars) and why it can never fail the request. On malformed output after one
retry — or a throttle, a timeout, or a re-ranker that scores asset ids it was
never sent — the response degrades to pure vector order with
rerank_status: "fallback". Relevance is an improvement to retrieval, not a
dependency of it.
That last case is not hypothetical defensiveness. It caught a real bug: the
score field was constrained to [0, 1] because that is the model's output
contract, but the fallback path reuses the same structure for raw cosine
similarity, which goes negative. The code whose only job was to never fail was
the only code that could not handle a negative number. It is now two types, and
there is a regression test.
Why fail-closed authorization, and identical not-found/not-authorized
Every tool calls require_scope(), including both read tools, because read
paths are where authorization bugs actually live — they feel harmless and get
less scrutiny. There are two enforcement points on purpose: the middleware
rejects unauthenticated requests before the MCP layer parses anything, and each
tool re-checks from a ContextVar. The second is not redundant. It is what
makes a tool reached by any other route — a unit test, a future stdio
transport, a refactor that forgets the middleware — still deny.
Fail-closed means every failure mode denies: no principal, unknown scope, and any exception during resolution. If Parameter Store is unavailable, the answer is 401, not "allow and log it". There is a test that patches the token store to raise and asserts the request never reaches the app.
NOT_FOUND and "exists but is not yours" are the same response, produced by a
single constructor, because anything else makes workspace membership
enumerable: probe ids, watch which return 403, and you have mapped another
tenant's library. A test asserts the two payloads are byte-identical.
Workspace scoping is applied inside the query, not to the result set: the
index masks out-of-workspace rows to -inf before top-k. Filtering afterwards
returns fewer than k results and leaks existence through result counts. The
test for this puts an asset with identical text in another workspace, so it is
the exact nearest neighbour and would surface immediately if the mask were
applied in the wrong order.
What it costs. A caller who genuinely lacks access cannot tell a typo from a permissions problem, which is a real support burden. The audit log records which it was, so an operator can still answer the question.
Why untrusted content is wrapped and flagged rather than filtered
Retrieved text is never concatenated into a system prompt and never interpreted as instruction. Anything asset-derived that leaves the server — to a client or into the re-ranker prompt — is wrapped:
<untrusted_asset_content asset_id="ast_7f3a91">
...verbatim text...
</untrusted_asset_content>The interesting part is what happens when a document contains that tag itself. Wrapping naively lets a document close its own wrapper and have everything after it read as trusted prose, so occurrences inside the content are neutralised first — case-insensitively, tolerating whitespace inside the tag, and including unterminated openers. The property under test is that hostile input still produces exactly one closing tag.
Suspected injections are flagged, not dropped. content_flags: ["possible_injection"] is advisory metadata; the document is still returned.
Silent filtering means the corpus disagrees with itself depending on content and
hides the attack from whoever reads the logs. The seed corpus contains four
poisoned documents in four different styles — a direct instruction override, a
wrapper escape, fake tool-call syntax, and a fake system prompt — and each has a
test asserting it comes back wrapped, flagged, and inert.
What it costs. The flag will over- and under-fire; it is a regex battery, not a classifier. That is acceptable precisely because nothing downstream acts on it. The real protection is the wrapper plus the rule that retrieved text is never executed.
Why this vector index at this scale
5,000 records × 1024 dimensions is a 19 MB file. A brute-force cosine similarity over it is exact and measures 0.6-3 ms locally, needs no service, costs nothing at idle, and cannot drift out of sync with the corpus. The alternative at this scale is paying for a managed vector store to get approximate answers to a problem a single matrix-vector product solves exactly. OpenSearch Serverless would be roughly $700/month idle to make this measurably worse.
The index is built by scripts/seed_corpus.py, stored in S3, and loaded once
per cold start. It is tagged with the embedding model id, and query time asserts
the match — a model swap is a loud failure rather than silently degraded
retrieval, which is the failure mode you would otherwise discover months later
in a metric.
The binding constraint is not search time — brute force stays comfortably fast well past 100k records. It is cold-start load: the index is fetched from S3 and parsed on every cold container.
At 20× (100k records, ~390 MB) the search is still only tens of milliseconds, but the Lambda needs 2 GB and the cold start moves into seconds. The first lever is not a different index, it is fewer dimensions: Titan V2 supports 512 and 256, and 256 dims would put 100k records back under 100 MB at a documented ~97% of retrieval accuracy. At 1,000× (5M records) in-process stops making sense at any dimension count, and it needs a real vector store — at which point the workspace mask must become a pre-filter pushed into the engine rather than a mask applied to a score vector, or you reintroduce exactly the "filter after ranking" bug this design avoids.
There is also a relevance floor (MIN_SCORE, default 0.20). Without one,
top-k always returns its nearest neighbours no matter how irrelevant, so
"nothing" can never be a correct answer — and four golden cases require exactly
that. It is applied before re-ranking, so an irrelevant candidate is never paid
for.
The value is measured, not guessed: over this corpus the highest-scoring off-topic query reaches 0.137 and the weakest true positive sits at 0.281, so 0.20 sits near the middle of that window rather than tuned to either edge. It is a per-embedding-model constant, currently calibrated against the fake embedder; it must be re-measured for Titan, because an uncalibrated floor returns nothing for good queries or junk for bad ones, and does so silently.
Lambda vs Fargate, and AgentCore vs self-hosted
Lambda + Function URL. Free tier covers a million requests a month, there is no per-request API Gateway charge, no container to build, and idle cost is exactly zero. Measured cold start for the built arm64 artifact in the Lambda runtime container is ~1.8 s, most of it importing numpy; warm invocations are single-digit milliseconds. For a demo a reviewer opens twice, paying 1.8 s once to pay nothing while idle is the right trade. Fargate would fix the cold start and cost roughly $9/month to sit there doing nothing.
API Gateway was rejected: it adds $1–3.50 per million requests and a second resource to buy throttling and WAF hooks this demo does not use. Reserved concurrency of 5 bounds runaway spend instead.
AgentCore Runtime was evaluated and rejected, per SPEC §11.3. It is genuinely
the purpose-built option — managed MCP hosting, agentcore deploy, stateless
and stateful modes — but three things disqualify it here:
It requires OAuth (Cognito/Auth0) or SigV4 inbound auth. A static bearer token is not an option, so a reviewer could not connect with a token alone, which is the entire deployment requirement.
It requires an ARM64 container in ECR, versus a zip.
It bills ~$0.0895/vCPU-hour and ~$0.00945/GB-hour per live session, so an idle demo with a lingering session is not free the way Lambda is.
If this were a real internal service behind a corporate IdP, points 1 and 2 invert and AgentCore becomes the better answer.
Eval results
make eval runs all 36 golden cases through the real search_assets path —
authorization, relevance floor, wrapping, re-ranking — not a stripped-down
harness. It prints a pass/fail table, writes evals/results/<timestamp>.json,
and exits non-zero below the thresholds committed in
evals/thresholds.json. Verified by raising the floor and confirming exit 1.
Latest committed run (BEDROCK_MODE=fake, 5,000 records):
Metric | Value | Floor |
recall@3 | 1.000 | 0.95 |
MRR | 1.000 | 0.95 |
mean latency | 51 ms | — |
p95 latency | 2.9 ms | — |
re-ranker fallback rate | 0.000 | ≤ 0.00 |
correct "nothing" answers | 4/4 | 4/4 |
scored / skipped | 27 / 9 | — |
Read the skip count before the recall number. Nine of the 36 cases are skipped in fake mode, and they are the nine hardest: seven need real embeddings (the query paraphrases rather than quotes, so the correct record never enters the candidate set) and two need a real re-ranker. A fake-mode pass therefore says "the 27 lexically-tractable cases still work". It is a regression guard, not evidence about deployed retrieval quality.
That is a deliberate choice over the alternative, which would have been to score those nine and set a threshold low enough to accommodate failing them. That produces a better-looking single number and a meaningless one: a genuine regression would hide inside the slack. Skipping them keeps the scored subset honest and makes the gap explicit.
The fake embedder is a deterministic stopword-filtered bag-of-words that needs
no credentials and costs nothing. It is lexical, so "coffee shop" never reaches
"Cafe interior" and "genomic" never reaches "whole-genome". Closing that gap is
exactly what Titan is for, and the replay/live thresholds — which score all
36 — are not yet validated. See Status.
CI runs lint, 286 tests against DynamoDB Local, and the eval suite on every push, with no AWS credentials and no possibility of spend.
Spend controls
The server is reachable at a public URL and the expensive thing it does — an LLM re-rank — is on the read path. A caller who cannot mutate anything can still cost money, so cost is treated as an abusable resource rather than a billing concern. Three independent layers, because each covers the others' gap:
1. In-request daily ceiling (real-time). Every re-rank adds its
estimated_cost_usd to an atomic DynamoDB counter for the UTC day, taken from
the same figure the audit entry records so the ledger and the log cannot
disagree. Before any Bedrock call, the counter is checked against
DAILY_BUDGET_USD. Over budget, search degrades to vector ordering rather
than erroring — the read path stays useful while spend is paused. The check
sits after retrieval, not at the top of the tool, because everything before it
is free and refusing a query that would have cost nothing is a worse failure
than serving it.
This layer fails open if the counter is unreadable — deliberately the opposite of authorization. An unavailable counter is a billing inconvenience; denying every request because of it turns that into an outage, and layer 2 still bounds the loss.
2. Account-level killswitch (delayed). An AWS Budget publishes to SNS, which invokes a killswitch Lambda, which sets the server's reserved concurrency to 0 — Lambda then throttles every invocation, so the function stops running and stops costing. This is independent of the application, so it still fires if layer 1 is wrong or bypassed.
It is not real-time. AWS Budgets evaluate roughly every 8–12 hours, so this can lag actual overspend by most of a day. It limits damage; it does not prevent a runaway within the hour. That is precisely why layer 1 exists, and why the static caps in layer 3 are not optional.
3. Static caps. Reserved concurrency 5, a per-principal rate limit, bounded candidate counts, and truncated summaries so a single prompt cannot grow without limit.
Why the killswitch cannot be manipulated
A control that the attacker can switch off is not a control. Three properties, all enforced in IAM rather than in code:
Only AWS Budgets can trip it. The SNS topic policy allows
sns:Publishfrombudgets.amazonaws.comalone, conditioned on this account id, and explicitly denies everyone else. Knowing the topic ARN buys nothing.The server cannot un-kill itself. The runtime role carries an explicit
Denyonlambda:PutFunctionConcurrency,DeleteFunctionConcurrency,UpdateFunctionConfiguration,budgets:ModifyBudget,budgets:DeleteBudget, and any write to the disabled marker in SSM. If the server is compromised through a dependency or anything else, it still cannot restore its own capacity or erase the record of why it was stopped. An explicit Deny beats any Allow, including one added later by mistake.Recovery is human-only and one-way. The killswitch role itself is denied
DeleteFunctionConcurrency, so it cannot undo its own action. Nothing re-enables the service automatically, because an automatic reset would turn a cost attack into a flapping loop — trip, recover, trip again — spending the budget repeatedly.make resumeshows the disable reason and current spend, requires typed confirmation, and needs IAM permissions neither Lambda role has.
The daily ceiling is separate and self-clearing: it resets at UTC midnight and needs no intervention.
tests/unit/test_budget.py asserts each of these against the template, because
the guarantee lives in IAM and a silent edit would remove it without breaking a
single behavioural test.
Security model
Principals. A bearer token maps to Principal(principal_id, workspace_id, scopes) with scopes drawn from {assets:read, assets:write}. An unrecognised
scope in configuration fails at load time rather than granting something
unknown at request time.
Token storage. Only sha256(token) is ever stored. scripts/make_token.py
prints the token once and writes the digest to an SSM SecureString; a reader of
that parameter, or of a leaked backup, cannot authenticate. SSM Parameter Store
Standard is free — Secrets Manager is $0.40/secret/month for the same job here.
Why the Function URL is AuthType: NONE. That setting means "no IAM SigV4",
not "no authentication". Every request is authenticated by AuthMiddleware.
SigV4 was rejected because a third-party MCP client cannot sign requests, which
would defeat the purpose of a URL someone can connect to with a token.
Why not the SDK's TokenVerifier. The MCP Python SDK has a sanctioned auth
path, and it is deliberately unused. It requires AuthSettings with an
issuer_url and resource_server_url, and it mounts OAuth Protected Resource
Metadata. There is no IdP here — these are static tokens — so wiring it up would
mean publishing discovery metadata for an authorization server that does not
exist and sending 401s inviting clients into an OAuth flow that cannot complete.
Instead the 401 is RFC 6750-shaped with no discovery pointer. The trade-off,
stated plainly: clients that self-onboard via discovery cannot; a token must be
handed over out of band. The migration path is to implement TokenVerifier and
supply AuthSettings — Principal is already shaped to be built from either.
Audit posture. Every call — success, failure, and denial — writes one entry with a sha256 of the inputs, a redacted input summary, latency, Bedrock token counts, and estimated cost. Raw idempotency keys and free text are never stored. Append-only is enforced twice, independently:
src/asset_mcp/store/audit.pycontains no update or delete function. A test asserts this by introspection.The runtime IAM role carries an explicit Deny on
dynamodb:UpdateItem,DeleteItem,BatchWriteItemandDeleteTableagainst the audit table. An explicit Deny beats any Allow, so the role cannot rewrite history even if the code grows a bug tomorrow. A test asserts the Deny is still in the template.
The audit table has no TTL, because a log that deletes itself is not an audit log. Audit writes are non-fatal by design — a broken audit table must not fail a read — but they log loudly.
Cost guardrails. See Spend controls for the three layers
and the tamper-resistance argument. Additionally, a $5 budget alarm lives in a
separate stack so sam delete of the application cannot remove it.
make teardown removes every billable resource, emptying the versioned bucket
first.
Status
Complete and verified locally:
All four tools, the propose/commit split, idempotency, audit, injection handling, spend controls, and the eval harness — 286 tests, green.
16 contract tests drive the assembled app over Streamable HTTP, including the Lambda handler across repeated invocations.
Full end-to-end run against a live local server: 5,000 records in DynamoDB Local, uvicorn serving the real ASGI app, and
scripts/smoke_test.pyspeaking Streamable HTTP over the network with nothing but a bearer token. All 15 checks pass, including propose → commit → replay and the byte-identical not-found response. All four poisoned documents were confirmed over the wire as wrapped, flagged, and carrying exactly one closing tag, with the embedded wrapper escape neutralised.Both CloudFormation templates pass
sam validate --lint; the arm64 artifact builds and was invoked successfully in the real Lambda runtime container.CI is green on GitHub Actions (lint, corpus build, golden-set drift check, 286 tests against DynamoDB Local, and the eval suite) in ~40s per run, with no AWS credentials configured and no possibility of spend.
Not yet done — blocked on the AWS account:
The stack has never been deployed. There is no live URL yet.
No Bedrock call has been made against the real API.
BedrockEmbedderandBedrockRerankerare written against the current documented Converse and InvokeModel shapes but are unexercised.evals/fixtures/is empty, soBEDROCK_MODE=replayhas nothing to replay; CI currently runs the eval infakemode, where 9 of 36 cases are skipped.MIN_SCOREis calibrated for the fake embedder over the measured (0.137, 0.281) window and must be re-measured against Titan's distribution.The killswitch has never fired. The IAM posture is asserted against the template, but no budget alarm has actually tripped it in anger.
The deploy workflow has never run. It is written and its YAML is valid, but no OIDC role exists yet, so nothing has authenticated to AWS.
See Deploying.
Deploying
Requires an AWS account with Bedrock model access enabled for
amazon.titan-embed-text-v2:0 and anthropic.claude-haiku-4-5 in us-east-1.
# 1. Budget alarm FIRST, before anything can spend (SPEC §14)
make budget ALERT_EMAIL=you@example.com
# 2. Stack: Lambda, Function URL, 4 tables, S3 bucket, IAM
make deploy # also locks ALLOWED_HOSTS to the Function URL host
# 3. Mint a token (printed once; only its digest is stored)
make token
# 4. Real embeddings, index to S3, assets to DynamoDB (~$0.001)
make seed-aws INDEX_BUCKET=$(aws cloudformation describe-stacks \
--stack-name asset-mcp \
--query "Stacks[0].Outputs[?OutputKey=='IndexBucketName'].OutputValue" --output text)
# 5. Verify end to end, as a third party would
make smoke MCP_URL=https://<id>.lambda-url.us-east-1.on.aws/mcp TOKEN=amcp_...Then connect a client:
claude mcp add --transport http asset-library \
https://<id>.lambda-url.us-east-1.on.aws/mcp \
--header "Authorization: Bearer amcp_..."Teardown: make teardown.
Expected cost
Service | Demo usage | Cost |
Lambda (arm64, 1024 MB) | < 10k invocations | $0 (1M/mo always free) |
Function URL | — | $0 |
DynamoDB on-demand ×4 | ~1 MB | ~$0 (25 GB free) |
S3 | 1.1 MB index | < $0.01 |
SSM Parameter Store | 1 SecureString | $0 |
Bedrock — Titan embed | full 5,000-record reindex | ~$0.01 |
Bedrock — Haiku 4.5 rerank | ~2k in / 300 out | ~$0.0035 per search |
Roughly $0.35 per 100 searches; under $1/month in practice.
What I'd do next
Honest list, roughly in the order I would do them.
Deploy it and record the fixtures. Everything above the Bedrock boundary is tested; the Bedrock calls themselves are not. Until the stack runs, the Converse request shape, the Titan response shape, and the inference-profile id are all reasoned-about rather than observed. Seeding 5,000 records is 5,000 InvokeModel calls (Titan V2 has no batch input), which is why the seeder uses a bounded thread pool with jittered backoff — that concurrency has been tested against a stub, not against real throttling.
Recalibrate
MIN_SCOREagainst Titan and re-measure the thresholds. A floor tuned to one embedding model's score distribution is meaningless for another, and shipping it uncalibrated would silently return "nothing" for good queries or junk for bad ones.Token rotation. The token store is cached for the container's lifetime, so revocation takes effect only when containers recycle. That is wrong for anything real; it needs a short TTL cache or a revocation check per request.
The rate limiter is per-container, not global. With several warm Lambdas the effective limit is a multiple of the configured one. It bounds runaway spend, which is what it is for, but it is not a security control and should not be mistaken for one.
The daily spend counter trusts the server's own reporting. A compromised server could under-report and evade layer 1. Layer 2 is the backstop, but it is slow; deriving spend from CloudWatch metrics or Cost Explorer instead would remove the self-reporting assumption.
Proposals expire but are never cleaned up in code. DynamoDB TTL reclaims them eventually — up to 48 hours late — which is why expiry is judged in code against
expires_at. A sweeper would make the table's contents match its semantics.The injection heuristic is a regex battery. It is honest about being advisory, but a small classifier would be a better signal, and the flag should probably carry a confidence rather than being boolean.
No structured logging or tracing. The audit table answers "what happened"; there is nothing answering "why was this slow". X-Ray or OTel plus JSON logs would be the next addition.
Single region, single environment, per the spec's non-goals. There is no staging, no canary, and no rollback beyond redeploying a previous commit.
Repo layout
src/asset_mcp/
server.py MCP server, tool registration, Starlette app, Lambda handler
auth.py Principal, AuthMiddleware, scope checks, rate limiting
config.py model ids, table names, limits, cost rates
errors.py structured error codes; the single not_found() constructor
schemas.py pydantic models for every tool I/O
corpus.py deterministic synthetic corpus incl. poisoned documents
budget.py in-request daily spend ceiling (layer 1)
killswitch.py budget-triggered stop, SNS-invoked (layer 2)
tools/ search, get_asset, propose, commit, and the shared audit wrapper
bedrock/ embeddings, rerank, record/replay cassette
store/ dynamo, repo, audit, vector index
safety/ untrusted-content wrapping and injection flags
infra/ SAM templates (budget deployed separately, first)
evals/ golden set, runner, thresholds, committed results
scripts/ seed, token minting, post-deploy, smoke test, resume, teardown
tests/ unit, integration (DynamoDB Local), contract (real MCP client)A note on the Lambda wiring in server.py, since it looks like boilerplate and
is not: streamable_http_app() returns a Starlette app whose lifespan is
StreamableHTTPSessionManager.run(), which raises if entered twice on one
instance. Mangum enters and exits the ASGI lifespan on every invocation, so the
obvious wiring serves exactly one request and then fails permanently on a warm
container. The lifespan is therefore entered once at cold start, on Mangum's
persistent event loop, with Mangum(lifespan="off"). A contract test invokes
the handler repeatedly to keep that honest.
This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Servers
- Alicense-qualityAmaintenanceEnables natural language search and discovery of open-access scientific datasets through the EOSC Data Commons OpenSearch service. Provides tools to search datasets and retrieve file metadata using LLM-assisted queries.14MIT
- FlicenseAqualityCmaintenanceProvides read-only access to DataCite's index of 125M+ research DOIs via natural language queries, enabling searching, metadata retrieval, citation formatting, and relationship exploration.9
- AlicenseAqualityAmaintenanceSearches and fetches research datasets across Zenodo, DataCite (Dryad/Figshare/Dataverse/OSF), NCBI omics archives (GEO/SRA/BioProject), and the literature (PubMed/OpenAIRE) through one normalized model — deduplicating by DOI, expanding organism queries with NCBI Taxonomy synonyms, and bridging papers to the datasets they produced. Resolves citations and open-access full text, and downloads files.62MIT
- Alicense-qualityDmaintenanceUnified MCP server for discovering open datasets across Hugging Face, Zenodo, and Kaggle, with ranked search results and one-click Colab starter code generation.1MIT
Related MCP Connectors
Software component catalog: search your org's services, docs, APIs, dependencies, and ownership.
Recommendations, search, catalogue, analytics, and platform admin tools for NeuronSearchLab
The grounded data layer for any LLM: governed SQL, metrics, lineage and catalog over your data.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/moiez-asif2002/asset-library-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server