Embeddings
The Capture MCP SDK is currently only available to Capture's internal developers. We are working hard on the cybersecurity part, so that we can also offer it to everyone who builds on top of Capture.
Capture can embed text on behalf of your app: EU-hosted models, billed per company against the identity of your app. You do not need an embedding provider of your own, credentials for one or a price table.
The embeddings client calls the app-authenticated embeddings endpoint of Captain with the identity of your app. It needs no MCP server, no callback port and no tools. It does need the address of Captain in your settings: capture_ai_base_url in Python and CaptureAiBaseUrl in .NET. Include any path prefix the deployment puts the AI service under. The routes are appended to the full path you give, so copy the address exactly as you were given it.
Set up and embed
- Python
- .NET
from capture_mcp_sdk import CaptureAppSettings, CaptureEmbeddingsClient, EmbeddingInputType
async with CaptureEmbeddingsClient(settings) as client:
models = await client.models() # read once at startup
one = await client.embed("bearing 3 running hot", company_id=42)
many = await client.embed_many(texts, company_id=42) # chunked, order preserved
wide = await client.embed_many(texts, company_id=42, dimensions=3072)
query = await client.embed(question, company_id=42, input_type=EmbeddingInputType.QUERY)
AddCaptureEmbeddings registers ICaptureEmbeddingsClient. It does not need AddCaptureMcpTools, so an app that only wants vectors registers only this:
builder.Services.AddCaptureEmbeddings(o =>
{
o.ApiBaseUrl = new Uri("https://api.example.com/"); // Capture platform API
o.CaptureAiBaseUrl = new Uri("https://captain.example.com/captain/"); // Captain
o.AppClientId = "shiftapp-client";
o.AppClientSecret = builder.Configuration["ShiftApp:ClientSecret"]!;
});
public sealed class ShiftIndexer(ICaptureEmbeddingsClient embeddings)
{
public async Task IndexAsync(IReadOnlyList<string> notes, CancellationToken ct)
{
// one text
CaptureEmbedding one = await embeddings.EmbedAsync("bearing 3 running hot", companyId: 42, ct);
// a batch: chunked against the limits of the deployment, order preserved
CaptureEmbeddingBatch many = await embeddings.EmbedAsync(notes, companyId: 42, ct);
// many.Vectors[i] is the embedding of notes[i], across chunk boundaries
}
}
You write no token code. The endpoint needs a client_credentials token for the scope of the platform API. The SDK requests it, caches it until shortly before it expires and reads it again on a retry. Do not add a token path of your own: a hand-rolled one forgets the scope, and a token you attach yourself is neither cached nor refreshed.
Read the models at startup
Read the models and the batch limits from the client. Never hardcode either. It reports what this deployment serves: the model ids, the default, the default and maximum width of each model, and the request limits. Read it once at startup, to create your vector column and to size your own work. The SDK reads it once for each process and reuses it, and it is also what batches are split against. A batch is one usage record, so a constant in your own code would make the same job cost something different depending on the client that ran it.
The limits refuse, they never truncate: a request over a limit is an error, not a silently shortened result. You do not have to split a batch yourself.
- Python
- .NET
models = await client.models() # servable models, default model, widths and limits
CaptureEmbeddingModels models = await embeddings.GetModelsAsync(ct);
var model = models.Models.Single(m => m.Model == models.DefaultModel);
logger.LogInformation(
"Embedding with {Model} at {Width}d (max {Max}d), max {MaxInputs} inputs per call.",
model.Model, model.DefaultDimensions, model.MaxDimensions, models.Limits.MaxInputs);
Store the model and the width with every vector
Every response echoes the model and the width it was produced at. Store both, for every row. If the default model or width of the deployment changes, the vectors you stored earlier sit in a different space and return plausible-looking, wrong neighbours, with no error anywhere.
Then pin them when you search. Pinning turns that silent drift into a refusal you can act on, and re-index on.
- Python
- .NET
Store result.model and result.dimensions next to every vector you persist, and pin the pair on later requests with the model and dimensions arguments.
row.Embedding = one.Values;
row.Model = one.Model; // for example azure/openai/text-embedding-3-large@francecentral
row.Dimensions = one.Dimensions;
var query = await embeddings.EmbedAsync(
searchText,
new CaptureEmbeddingRequest
{
CompanyId = 42,
Model = row.Model, // what the stored vectors were made with
Dimensions = row.Dimensions,
InputType = CaptureEmbeddingInputType.Query,
},
ct);
Choose dimensions: mind the pgvector limit
The width is optional and defaults to the own width of the model. When you do set it, the number has to survive your index, not just your column:
| Storage type | HNSW / IVFFlat index limit |
|---|---|
vector | 2000 dimensions |
halfvec | 4000 dimensions |
A vector of 3072 dimensions therefore cannot be indexed as vector. Store it as halfvec(3072), or ask for a width of 2000 or less. Nothing complains when you insert the rows. The failure only shows up when CREATE INDEX runs, which is usually long after the embedding bill.
-- 3072 wide: halfvec, or the index will not build
CREATE TABLE note_embedding (
note_id bigint PRIMARY KEY,
model text NOT NULL, -- persist what produced the vector
dimensions integer NOT NULL,
embedding halfvec(3072) NOT NULL
);
CREATE INDEX ON note_embedding USING hnsw (embedding halfvec_cosine_ops);
If your model is matryoshka-style (dimension_reducible on its catalog entry), a smaller width is a genuine trade instead of a downgrade: less storage and a faster index, for slightly less recall.
- Python
- .NET
wide = await client.embed_many(texts, company_id=42, dimensions=3072)
var wide = await embeddings.EmbedAsync(
notes,
new CaptureEmbeddingRequest { CompanyId = 42, Dimensions = 3072 },
ct);
InputType
Use document when you embed something you will store, and query when you embed the text a user typed to search with. It defaults to document. Models that are asymmetric embed the two roles differently, so using one setting for both costs you recall for nothing. It also selects the role the spend is attributed under.
To calibrate how much this matters today: every model that is served right now is one of the text-embedding-3-* models of OpenAI, which are symmetric. They return the same vector for either setting. The split in the bill is real now. The effect on recall becomes real the moment an asymmetric model is served. Set it correctly either way, because your stored vectors outlive the model that produced them.
In Python this is EmbeddingInputType.QUERY, in .NET CaptureEmbeddingInputType.Query.
Errors
Branch on the machine-readable code and status. Never parse the message or the detail, which are English fallbacks for developers.
- Python
- .NET
A failure arrives as a CaptureEmbeddingError. It carries the members of the problem document: code (a CaptureEmbeddingErrorCodes constant, identical to the one in the .NET client), status, permitted_dimensions on a refusal of the width and servable_models on a refusal of the model.
A failure throws a CaptureEmbeddingException. It carries Code, Status, ServableModels and PermittedDimensions, and Extensions holds every other member of the problem document as data.
code | Status | What to do |
|---|---|---|
EMBEDDING_MODEL_NOT_SERVABLE | 422 | You pinned a model this deployment will not serve. Pick one of the servable models, and re-index onto it. Do not silently fall back: the spaces differ. |
EMBEDDING_DIMENSIONS_UNSUPPORTED | 422 | The model cannot produce that width. The permitted dimensions list what it can. Fix the request and your column. Retrying is pointless. |
EMBEDDING_INPUT_INVALID | 422 | An empty input, or a missing or non-positive company id. Fix the call. Retrying is pointless. |
EMBEDDING_REQUEST_TOO_LARGE | 413 | The batch went over a published limit. Batches are already split against the limits, so this means one input is over the cap for a single input. Split the text yourself. |
| (none) | 401, 403 | The credentials of the app were rejected, or the client is not linked to a Capture app. Check the client id and secret. The SDK does not retry these. |
| (none) | 429, 5xx | Throttled or temporary. The SDK retries with exponential backoff that honours Retry-After. If it still shows up, slow your ingest down. |
No other 4xx is retried, because a refused model or width is refused the same way the next time. Nothing on any path logs or echoes your input text or your client secret.
Test without spending money
The mock of each SDK serves both embeddings routes in process. It gives the same refusal codes, the same statuses and deterministic vectors, so the same text gives the same numbers on every run and in both SDKs. There is no network, no Docker and no bill.
- Python
- .NET
from capture_mcp_sdk import CaptureAppSettings, CaptureEmbeddingsClient
from capture_mcp_sdk.identity import CaptureAppIdentity
from capture_mcp_sdk.testing import CaptureEmbeddingsMock, MockEmbeddingLimits, MockEmbeddingModel
mock = CaptureEmbeddingsMock(
models=[MockEmbeddingModel(model="acme/embed", default_dimensions=1024, max_dimensions=3072)],
default_model="acme/embed",
limits=MockEmbeddingLimits(max_inputs=2), # small, so chunking is exercised by 5 inputs
)
settings = CaptureAppSettings(
api_base_url="https://api.example.invalid/",
capture_ai_base_url="https://ai.example.invalid/",
app_client_id="your-app-client",
app_client_secret="not-a-real-secret",
)
async with mock.http_client() as http:
identity = CaptureAppIdentity(settings, http_client=http)
client = CaptureEmbeddingsClient(settings, identity=identity, http_client=http)
batch = await client.embed_many(["a", "b", "c", "d", "e"], company_id=42)
assert len(mock.calls) == 3 # 5 inputs, 2 per call
assert mock.calls[0].inputs == ("a", "b")
assert mock.calls[0].company_id == 42
The mock also answers POST /connect/token, which is why the whole client runs without a network. It is also why the module is for tests only and must never be imported from a path that runs in production.
| Member | What it does |
|---|---|
mock.calls | Records every POST /embeddings: the inputs, the company_id, the model and width you pinned, the model and width that were resolved, and the refusal code when one was given. |
mock.fail_with(...), mock.fail_next_with(...) | Drive your error handling, for example with MockEmbeddingFailure.problem(422, MockEmbeddingErrorCodes.MODEL_NOT_SERVABLE), MockEmbeddingFailure.too_many_requests(retry_after_seconds=2) or MockEmbeddingFailure.status_only(502). fail_next_with works once, so the call after it succeeds. That is how you watch a retry recover. |
with mock.serve() as server: | Starts a real loopback HTTP server, for code that only takes a base URL. server.base_url is both api_base_url and capture_ai_base_url. |
CaptureMcpMockServer from Capture.Cloud.Mcp.Sdk.Testing mocks the embeddings routes as well as the MCP ones:
await using var mock = await CaptureMcpMockServer.StartAsync(o =>
// A small limit exercises your chunking without a thousand-element fixture.
o.EmbeddingLimits = new MockEmbeddingLimits(MaxInputs: 2));
var services = new ServiceCollection();
services.AddLogging();
services.AddCaptureEmbeddings(o =>
{
o.ApiBaseUrl = mock.ApiBaseUrl;
o.CaptureAiBaseUrl = mock.CaptureAiBaseUrl;
o.AppClientId = "test-client";
o.AppClientSecret = "test-secret";
});
using var provider = services.BuildServiceProvider();
var embeddings = provider.GetRequiredService<ICaptureEmbeddingsClient>();
string[] inputs = ["one", "two", "three", "four", "five"];
var batch = await embeddings.EmbedAsync(inputs, companyId: 42, ct);
batch.Vectors.Should().HaveCount(5);
mock.EmbeddingCalls.Should().HaveCount(3); // 2 + 2 + 1
mock.EmbeddingCalls.SelectMany(c => c.Inputs).Should().Equal(inputs); // request order kept
mock.EmbeddingCalls[0].CompanyId.Should().Be(42); // billed to the right company
Configure the catalog that is served with DefaultEmbeddingModel, ServableEmbeddingModels and EmbeddingLimits. Force a failure with mock.FailEmbeddingsWith(...) (it stays) or mock.FailNextEmbeddingsCallWith(...) (it happens once), using MockEmbeddingFailure.Problem(422, MockEmbeddingErrorCodes.ModelNotServable) or MockEmbeddingFailure.TooManyRequests(retryAfterSeconds: 1) for a throttle that your retry path has to survive. mock.EmbeddingModelsCallCount shows that you read the catalog once instead of for every call.